From 1c0162c43457e7e7a11c215b014aa537af67bbc4 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 5 May 2023 16:22:54 -0400 Subject: [PATCH 01/97] WIP: Twilio --- cmd/serve.go | 11 +++++ server/config.go | 6 +++ server/errors.go | 2 + server/log.go | 1 + server/server.go | 45 ++++++++++++------ server/server.yml | 6 +++ server/server_twilio.go | 101 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 server/server_twilio.go diff --git a/cmd/serve.go b/cmd/serve.go index 912e295..bef09e1 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -71,6 +71,9 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for SMS and calling, e.g. AC123..."}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-from-number", Aliases: []string{"twilio_from_number"}, EnvVars: []string{"NTFY_TWILIO_FROM_NUMBER"}, Usage: "Twilio number to use for outgoing calls and text messages"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), @@ -151,6 +154,9 @@ func execServe(c *cli.Context) error { smtpServerListen := c.String("smtp-server-listen") smtpServerDomain := c.String("smtp-server-domain") smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") + twilioAccount := c.String("twilio-account") + twilioAuthToken := c.String("twilio-auth-token") + twilioFromNumber := c.String("twilio-from-number") totalTopicLimit := c.Int("global-topic-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting") @@ -209,6 +215,8 @@ func execServe(c *cli.Context) error { return errors.New("cannot set enable-signup without also setting enable-login") } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") + } else if twilioAccount != "" && (twilioAuthToken == "" || twilioFromNumber == "" || baseURL == "") { + return errors.New("if stripe-account is set, twilio-auth-token, twilio-from-number and base-url must also be set") } // Backwards compatibility @@ -308,6 +316,9 @@ func execServe(c *cli.Context) error { conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix + conf.TwilioAccount = twilioAccount + conf.TwilioAuthToken = twilioAuthToken + conf.TwilioFromNumber = twilioFromNumber conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit diff --git a/server/config.go b/server/config.go index 59da448..8fc0141 100644 --- a/server/config.go +++ b/server/config.go @@ -105,6 +105,9 @@ type Config struct { SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string + TwilioAccount string + TwilioAuthToken string + TwilioFromNumber string MetricsEnable bool MetricsListenHTTP string ProfileListenHTTP string @@ -183,6 +186,9 @@ func NewConfig() *Config { SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", + TwilioAccount: "", + TwilioAuthToken: "", + TwilioFromNumber: "", MessageLimit: DefaultMessageLengthLimit, MinDelay: DefaultMinDelay, MaxDelay: DefaultMaxDelay, diff --git a/server/errors.go b/server/errors.go index 8e56519..236b4e0 100644 --- a/server/errors.go +++ b/server/errors.go @@ -106,6 +106,8 @@ var ( errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil} errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil} errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil} + errHTTPBadRequestTwilioDisabled = &errHTTP{40030, http.StatusBadRequest, "invalid request: SMS and calling is disabled", "https://ntfy.sh/docs/publish/#sms", nil} + errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40031, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#sms", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/log.go b/server/log.go index 643f2cc..c638ed9 100644 --- a/server/log.go +++ b/server/log.go @@ -20,6 +20,7 @@ const ( tagFirebase = "firebase" tagSMTP = "smtp" // Receive email tagEmail = "email" // Send email + tagTwilio = "twilio" tagFileCache = "file_cache" tagMessageCache = "message_cache" tagStripe = "stripe" diff --git a/server/server.go b/server/server.go index c0ebc6e..7bc096f 100644 --- a/server/server.go +++ b/server/server.go @@ -98,6 +98,7 @@ var ( docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) urlRegex = regexp.MustCompile(`^https?://`) + phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}`) //go:embed site webFs embed.FS @@ -668,7 +669,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e return nil, err } m := newDefaultMessage(t.ID, "") - cache, firebase, email, unifiedpush, e := s.parsePublishParams(r, m) + cache, firebase, email, sms, call, unifiedpush, e := s.parsePublishParams(r, m) if e != nil { return nil, e.With(t) } @@ -722,6 +723,12 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e if s.smtpSender != nil && email != "" { go s.sendEmail(v, m, email) } + if s.config.TwilioAccount != "" && sms != "" { + go s.sendSMS(v, r, m, sms) + } + if call != "" { + go s.callPhone(v, r, m, call) + } if s.config.UpstreamBaseURL != "" { go s.forwardPollRequest(v, m) } @@ -831,7 +838,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { } } -func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err *errHTTP) { +func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, sms, call string, unifiedpush bool, err *errHTTP) { cache = readBoolParam(r, true, "x-cache", "cache") firebase = readBoolParam(r, true, "x-firebase", "firebase") m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t")) @@ -847,7 +854,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if attach != "" { if !urlRegex.MatchString(attach) { - return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid + return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid } m.Attachment.URL = attach if m.Attachment.Name == "" { @@ -865,13 +872,25 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if icon != "" { if !urlRegex.MatchString(icon) { - return false, false, "", false, errHTTPBadRequestIconURLInvalid + return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid } m.Icon = icon } email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") if s.smtpSender == nil && email != "" { - return false, false, "", false, errHTTPBadRequestEmailDisabled + return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled + } + sms = readParam(r, "x-sms", "sms") + if sms != "" && s.config.TwilioAccount == "" { + return false, false, "", "", "", false, errHTTPBadRequestTwilioDisabled + } else if sms != "" && !phoneNumberRegex.MatchString(sms) { + return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid + } + call = readParam(r, "x-call", "call") + if call != "" && s.config.TwilioAccount == "" { + return false, false, "", "", "", false, errHTTPBadRequestTwilioDisabled + } else if call != "" && !phoneNumberRegex.MatchString(call) { + return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid } messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") if messageStr != "" { @@ -880,7 +899,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi var e error m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) if e != nil { - return false, false, "", false, errHTTPBadRequestPriorityInvalid + return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid } m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") for i, t := range m.Tags { @@ -889,18 +908,18 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") if delayStr != "" { if !cache { - return false, false, "", false, errHTTPBadRequestDelayNoCache + return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache } if email != "" { - return false, false, "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) + return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) } delay, err := util.ParseFutureTime(delayStr, time.Now()) if err != nil { - return false, false, "", false, errHTTPBadRequestDelayCannotParse + return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse } else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() { - return false, false, "", false, errHTTPBadRequestDelayTooSmall + return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall } else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() { - return false, false, "", false, errHTTPBadRequestDelayTooLarge + return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge } m.Time = delay.Unix() } @@ -908,7 +927,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if actionsStr != "" { m.Actions, e = parseActions(actionsStr) if e != nil { - return false, false, "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) + return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) } } unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! @@ -922,7 +941,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi cache = false email = "" } - return cache, firebase, email, unifiedpush, nil + return cache, firebase, email, sms, call, unifiedpush, nil } // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. diff --git a/server/server.yml b/server/server.yml index 204005c..9e515ec 100644 --- a/server/server.yml +++ b/server/server.yml @@ -144,6 +144,12 @@ # smtp-server-domain: # smtp-server-addr-prefix: +# If enabled, ntfy can send SMS text messages and do voice calls via Twilio, and the "X-SMS" and "X-Call" headers. +# +# twilio-account: +# twilio-auth-token: +# twilio-from-number: + # Interval in which keepalive messages are sent to the client. This is to prevent # intermediaries closing the connection for inactivity. # diff --git a/server/server_twilio.go b/server/server_twilio.go new file mode 100644 index 0000000..a9a2edb --- /dev/null +++ b/server/server_twilio.go @@ -0,0 +1,101 @@ +package server + +import ( + "fmt" + "heckel.io/ntfy/log" + "heckel.io/ntfy/util" + "io" + "net/http" + "net/url" + "strings" +) + +const ( + twilioMessageEndpoint = "Messages.json" + twilioCallEndpoint = "Calls.json" + twilioCallTemplate = ` + + + You have a message from notify on topic %s. Message: + + %s + + End message. + + %s + +` +) + +func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { + body := fmt.Sprintf("%s\n\n--\n%s", m.Message, s.messageFooter(m)) + data := url.Values{} + data.Set("From", s.config.TwilioFromNumber) + data.Set("To", to) + data.Set("Body", body) + s.performTwilioRequest(v, r, m, twilioMessageEndpoint, to, body, data) +} + +func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { + body := fmt.Sprintf(twilioCallTemplate, m.Topic, m.Message, s.messageFooter(m)) + data := url.Values{} + data.Set("From", s.config.TwilioFromNumber) + data.Set("To", to) + data.Set("Twiml", body) + s.performTwilioRequest(v, r, m, twilioCallEndpoint, to, body, data) +} + +func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, endpoint, to, body string, data url.Values) { + logContext := log.Context{ + "twilio_from": s.config.TwilioFromNumber, + "twilio_to": to, + } + ev := logvrm(v, r, m).Tag(tagTwilio).Fields(logContext) + if ev.IsTrace() { + ev.Field("twilio_body", body).Trace("Sending Twilio request") + } else if ev.IsDebug() { + ev.Debug("Sending Twilio request") + } + response, err := s.performTwilioRequestInternal(endpoint, data) + if err != nil { + ev. + Field("twilio_body", body). + Field("twilio_response", response). + Err(err). + Warn("Error sending Twilio request") + return + } + if ev.IsTrace() { + ev.Field("twilio_response", response).Trace("Received successful Twilio response") + } else if ev.IsDebug() { + ev.Debug("Received successful Twilio response") + } +} + +func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) (string, error) { + requestURL := fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/%s", s.config.TwilioAccount, endpoint) + req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + response, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(response), nil +} + +func (s *Server) messageFooter(m *message) string { + topicURL := s.config.BaseURL + "/" + m.Topic + sender := m.Sender.String() + if m.User != "" { + sender = fmt.Sprintf("%s (%s)", m.User, m.Sender) + } + return fmt.Sprintf("This message was sent by %s via %s", sender, util.ShortTopicURL(topicURL)) +} From 3863357207efbedf89b5245f73c073d3866a6dcc Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 5 May 2023 20:14:46 -0400 Subject: [PATCH 02/97] WIP --- server/config.go | 2 ++ server/server_twilio.go | 21 ++++++++++++++------ server/server_twilio_test.go | 38 ++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 server/server_twilio_test.go diff --git a/server/config.go b/server/config.go index 8fc0141..4f1cbef 100644 --- a/server/config.go +++ b/server/config.go @@ -105,6 +105,7 @@ type Config struct { SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string + TwilioBaseURL string TwilioAccount string TwilioAuthToken string TwilioFromNumber string @@ -186,6 +187,7 @@ func NewConfig() *Config { SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", + TwilioBaseURL: "https://api.twilio.com", // Override for tests TwilioAccount: "", TwilioAuthToken: "", TwilioFromNumber: "", diff --git a/server/server_twilio.go b/server/server_twilio.go index a9a2edb..fc21aac 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -1,6 +1,8 @@ package server import ( + "bytes" + "encoding/xml" "fmt" "heckel.io/ntfy/log" "heckel.io/ntfy/util" @@ -11,9 +13,10 @@ import ( ) const ( - twilioMessageEndpoint = "Messages.json" - twilioCallEndpoint = "Calls.json" - twilioCallTemplate = ` + twilioMessageEndpoint = "Messages.json" + twilioMessageFooterFormat = "This message was sent by %s via %s" + twilioCallEndpoint = "Calls.json" + twilioCallFormat = ` You have a message from notify on topic %s. Message: @@ -37,7 +40,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { } func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { - body := fmt.Sprintf(twilioCallTemplate, m.Topic, m.Message, s.messageFooter(m)) + body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(m))) data := url.Values{} data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) @@ -73,7 +76,7 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, e } func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) (string, error) { - requestURL := fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/%s", s.config.TwilioAccount, endpoint) + requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/%s", s.config.TwilioBaseURL, s.config.TwilioAccount, endpoint) req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) if err != nil { return "", err @@ -97,5 +100,11 @@ func (s *Server) messageFooter(m *message) string { if m.User != "" { sender = fmt.Sprintf("%s (%s)", m.User, m.Sender) } - return fmt.Sprintf("This message was sent by %s via %s", sender, util.ShortTopicURL(topicURL)) + return fmt.Sprintf(twilioMessageFooterFormat, sender, util.ShortTopicURL(topicURL)) +} + +func xmlEscapeText(text string) string { + var buf bytes.Buffer + _ = xml.EscapeText(&buf, []byte(text)) + return buf.String() } diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go new file mode 100644 index 0000000..16c1274 --- /dev/null +++ b/server/server_twilio_test.go @@ -0,0 +1,38 @@ +package server + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestServer_Twilio_SMS(t *testing.T) { + c := newTestConfig(t) + c.TwilioBaseURL = "http://" + c.TwilioAccount = "AC123" + c.TwilioAuthToken = "secret-token" + c.TwilioFromNumber = "+123456789" + s := newTestServer(t, c) + + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "SMS": "+11122233344", + }) + require.Equal(t, 1, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/send?priority=low", "test", nil) + require.Equal(t, 2, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/send?priority=default", "test", nil) + require.Equal(t, 3, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/send?priority=high", "test", nil) + require.Equal(t, 4, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/send?priority=max", "test", nil) + require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil) + require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/trigger?priority=INVALID", "test", nil) + require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) +} From 113b7c8a086e389bf0e12ebceb143fc40158188c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 6 May 2023 14:23:48 -0400 Subject: [PATCH 03/97] Metrics, tests --- server/server.go | 2 +- server/server_metrics.go | 20 ++++++ server/server_twilio.go | 9 ++- server/server_twilio_test.go | 122 ++++++++++++++++++++++++++++------- 4 files changed, 126 insertions(+), 27 deletions(-) diff --git a/server/server.go b/server/server.go index 7bc096f..1a9309c 100644 --- a/server/server.go +++ b/server/server.go @@ -98,7 +98,7 @@ var ( docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) urlRegex = regexp.MustCompile(`^https?://`) - phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}`) + phoneNumberRegex = regexp.MustCompile(`^\+\d{1,100}$`) //go:embed site webFs embed.FS diff --git a/server/server_metrics.go b/server/server_metrics.go index d3f1792..d2e6f1c 100644 --- a/server/server_metrics.go +++ b/server/server_metrics.go @@ -15,6 +15,10 @@ var ( metricEmailsPublishedFailure prometheus.Counter metricEmailsReceivedSuccess prometheus.Counter metricEmailsReceivedFailure prometheus.Counter + metricSMSSentSuccess prometheus.Counter + metricSMSSentFailure prometheus.Counter + metricCallsMadeSuccess prometheus.Counter + metricCallsMadeFailure prometheus.Counter metricUnifiedPushPublishedSuccess prometheus.Counter metricMatrixPublishedSuccess prometheus.Counter metricMatrixPublishedFailure prometheus.Counter @@ -57,6 +61,18 @@ func initMetrics() { metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{ Name: "ntfy_emails_received_failure", }) + metricSMSSentSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_sms_sent_success", + }) + metricSMSSentFailure = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_sms_sent_failure", + }) + metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_calls_made_success", + }) + metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "ntfy_calls_made_failure", + }) metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{ Name: "ntfy_unifiedpush_published_success", }) @@ -95,6 +111,10 @@ func initMetrics() { metricEmailsPublishedFailure, metricEmailsReceivedSuccess, metricEmailsReceivedFailure, + metricSMSSentSuccess, + metricSMSSentFailure, + metricCallsMadeSuccess, + metricCallsMadeFailure, metricUnifiedPushPublishedSuccess, metricMatrixPublishedSuccess, metricMatrixPublishedFailure, diff --git a/server/server_twilio.go b/server/server_twilio.go index fc21aac..fc5fb65 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/xml" "fmt" + "github.com/prometheus/client_golang/prometheus" "heckel.io/ntfy/log" "heckel.io/ntfy/util" "io" @@ -36,7 +37,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) data.Set("Body", body) - s.performTwilioRequest(v, r, m, twilioMessageEndpoint, to, body, data) + s.performTwilioRequest(v, r, m, metricSMSSentSuccess, metricSMSSentFailure, twilioMessageEndpoint, to, body, data) } func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { @@ -45,10 +46,10 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) data.Set("Twiml", body) - s.performTwilioRequest(v, r, m, twilioCallEndpoint, to, body, data) + s.performTwilioRequest(v, r, m, metricCallsMadeSuccess, metricCallsMadeFailure, twilioCallEndpoint, to, body, data) } -func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, endpoint, to, body string, data url.Values) { +func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, msuccess, mfailure prometheus.Counter, endpoint, to, body string, data url.Values) { logContext := log.Context{ "twilio_from": s.config.TwilioFromNumber, "twilio_to": to, @@ -66,6 +67,7 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, e Field("twilio_response", response). Err(err). Warn("Error sending Twilio request") + minc(mfailure) return } if ev.IsTrace() { @@ -73,6 +75,7 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, e } else if ev.IsDebug() { ev.Debug("Received successful Twilio response") } + minc(msuccess) } func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) (string, error) { diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 16c1274..d99f9b6 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -2,37 +2,113 @@ package server import ( "github.com/stretchr/testify/require" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" "testing" ) func TestServer_Twilio_SMS(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Messages.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "Body=test%0A%0A--%0AThis+message+was+sent+by+9.9.9.9+via+ntfy.sh%2Fmytopic&From=%2B1234567890&To=%2B11122233344", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + c := newTestConfig(t) - c.TwilioBaseURL = "http://" - c.TwilioAccount = "AC123" - c.TwilioAuthToken = "secret-token" - c.TwilioFromNumber = "+123456789" + c.BaseURL = "https://ntfy.sh" + c.TwilioBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" s := newTestServer(t, c) response := request(t, s, "POST", "/mytopic", "test", map[string]string{ "SMS": "+11122233344", }) - require.Equal(t, 1, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/send?priority=low", "test", nil) - require.Equal(t, 2, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/send?priority=default", "test", nil) - require.Equal(t, 3, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/send?priority=high", "test", nil) - require.Equal(t, 4, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/send?priority=max", "test", nil) - require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil) - require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) - - response = request(t, s, "GET", "/mytopic/trigger?priority=INVALID", "test", nil) - require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, "test", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + +func TestServer_Twilio_Call(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EYou+have+a+message+from+notify+on+topic+mytopic.+Message%3A%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3Ethis+message+has%26%23xA%3Ba+new+line+and+%26lt%3Bbrackets%26gt%3B%21%26%23xA%3Band+%26%2334%3Bquotes+and+other+%26%2339%3Bquotes%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EEnd+message.%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EThis+message+was+sent+by+9.9.9.9+via+127.0.0.1%3A12345%2Fmytopic%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfig(t) + c.TwilioBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + + body := `this message has +a new line and ! +and "quotes and other 'quotes` + response := request(t, s, "POST", "/mytopic", body, map[string]string{ + "x-call": "+11122233344", + }) + require.Equal(t, "this message has\na new line and !\nand \"quotes and other 'quotes", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + +func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { + c := newTestConfig(t) + c.TwilioBaseURL = "https://127.0.0.1" + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-call": "+invalid", + }) + require.Equal(t, 40031, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_Twilio_SMS_InvalidNumber(t *testing.T) { + c := newTestConfig(t) + c.TwilioBaseURL = "https://127.0.0.1" + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-sms": "+invalid", + }) + require.Equal(t, 40031, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_Twilio_SMS_Unconfigured(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-sms": "+1234", + }) + require.Equal(t, 40030, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_Twilio_Call_Unconfigured(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "x-call": "+1234", + }) + require.Equal(t, 40030, toHTTPError(t, response.Body.String()).Code) } From f9e2d6ddcbe829ca1297ebc7bf9c455836281508 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 7 May 2023 11:59:15 -0400 Subject: [PATCH 04/97] Add limiters and database changes --- cmd/serve.go | 6 ++++ cmd/tier.go | 16 +++++++++ server/config.go | 4 +++ server/errors.go | 2 ++ server/server.go | 6 +++- server/server.yml | 10 ++++-- server/server_account.go | 6 ++++ server/types.go | 6 ++++ server/visitor.go | 70 ++++++++++++++++++++++++++++++++----- user/manager.go | 75 +++++++++++++++++++++++++++++----------- user/types.go | 4 +++ 11 files changed, 173 insertions(+), 32 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index bef09e1..6c72975 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -83,6 +83,8 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-sms-daily-limit", Aliases: []string{"visitor_sms_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_SMS_DAILY_LIMIT"}, Value: server.DefaultVisitorSMSDailyLimit, Usage: "max number of SMS messages per visitor per day"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-call-daily-limit", Aliases: []string{"visitor_call_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_CALL_DAILY_LIMIT"}, Value: server.DefaultVisitorCallDailyLimit, Usage: "max number of phone calls per visitor per day"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), @@ -168,6 +170,8 @@ func execServe(c *cli.Context) error { visitorMessageDailyLimit := c.Int("visitor-message-daily-limit") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish") + visitorSMSDailyLimit := c.Int("visitor-sms-daily-limit") + visitorCallDailyLimit := c.Int("visitor-call-daily-limit") behindProxy := c.Bool("behind-proxy") stripeSecretKey := c.String("stripe-secret-key") stripeWebhookKey := c.String("stripe-webhook-key") @@ -329,6 +333,8 @@ func execServe(c *cli.Context) error { conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish + conf.VisitorSMSDailyLimit = visitorSMSDailyLimit + conf.VisitorCallDailyLimit = visitorCallDailyLimit conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting conf.BehindProxy = behindProxy conf.StripeSecretKey = stripeSecretKey diff --git a/cmd/tier.go b/cmd/tier.go index c0b83d7..6b95bdd 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -18,6 +18,8 @@ const ( defaultMessageLimit = 5000 defaultMessageExpiryDuration = "12h" defaultEmailLimit = 20 + defaultSMSLimit = 10 + defaultCallLimit = 10 defaultReservationLimit = 3 defaultAttachmentFileSizeLimit = "15M" defaultAttachmentTotalSizeLimit = "100M" @@ -48,6 +50,8 @@ var cmdTier = &cli.Command{ &cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"}, &cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"}, &cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"}, + &cli.Int64Flag{Name: "sms-limit", Value: defaultSMSLimit, Usage: "daily SMS limit"}, + &cli.Int64Flag{Name: "call-limit", Value: defaultCallLimit, Usage: "daily phone call limit"}, &cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"}, &cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"}, &cli.StringFlag{Name: "attachment-total-size-limit", Value: defaultAttachmentTotalSizeLimit, Usage: "total size limit of attachments for the user"}, @@ -91,6 +95,8 @@ Examples: &cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"}, &cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"}, &cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"}, + &cli.Int64Flag{Name: "sms-limit", Usage: "daily SMS limit"}, + &cli.Int64Flag{Name: "call-limit", Usage: "daily phone call limit"}, &cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"}, &cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"}, &cli.StringFlag{Name: "attachment-total-size-limit", Usage: "total size limit of attachments for the user"}, @@ -215,6 +221,8 @@ func execTierAdd(c *cli.Context) error { MessageLimit: c.Int64("message-limit"), MessageExpiryDuration: messageExpiryDuration, EmailLimit: c.Int64("email-limit"), + SMSLimit: c.Int64("sms-limit"), + CallLimit: c.Int64("call-limit"), ReservationLimit: c.Int64("reservation-limit"), AttachmentFileSizeLimit: attachmentFileSizeLimit, AttachmentTotalSizeLimit: attachmentTotalSizeLimit, @@ -267,6 +275,12 @@ func execTierChange(c *cli.Context) error { if c.IsSet("email-limit") { tier.EmailLimit = c.Int64("email-limit") } + if c.IsSet("sms-limit") { + tier.SMSLimit = c.Int64("sms-limit") + } + if c.IsSet("call-limit") { + tier.CallLimit = c.Int64("call-limit") + } if c.IsSet("reservation-limit") { tier.ReservationLimit = c.Int64("reservation-limit") } @@ -357,6 +371,8 @@ func printTier(c *cli.Context, tier *user.Tier) { fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit) fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds())) fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit) + fmt.Fprintf(c.App.ErrWriter, "- SMS limit: %d\n", tier.SMSLimit) + fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit) fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit) fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit)) fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit)) diff --git a/server/config.go b/server/config.go index 4f1cbef..b6d57d9 100644 --- a/server/config.go +++ b/server/config.go @@ -47,6 +47,8 @@ const ( DefaultVisitorMessageDailyLimit = 0 DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitReplenish = time.Hour + DefaultVisitorSMSDailyLimit = 10 + DefaultVisitorCallDailyLimit = 10 DefaultVisitorAccountCreationLimitBurst = 3 DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour DefaultVisitorAuthFailureLimitBurst = 30 @@ -126,6 +128,8 @@ type Config struct { VisitorMessageDailyLimit int VisitorEmailLimitBurst int VisitorEmailLimitReplenish time.Duration + VisitorSMSDailyLimit int + VisitorCallDailyLimit int VisitorAccountCreationLimitBurst int VisitorAccountCreationLimitReplenish time.Duration VisitorAuthFailureLimitBurst int diff --git a/server/errors.go b/server/errors.go index 236b4e0..d02fb07 100644 --- a/server/errors.go +++ b/server/errors.go @@ -126,6 +126,8 @@ var ( errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil} errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit + errHTTPTooManyRequestsLimitSMS = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily SMS quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} + errHTTPTooManyRequestsLimitCalls = &errHTTP{42911, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil} errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil} errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil} diff --git a/server/server.go b/server/server.go index 1a9309c..8c2f83c 100644 --- a/server/server.go +++ b/server/server.go @@ -683,6 +683,10 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e return nil, errHTTPTooManyRequestsLimitMessages.With(t) } else if email != "" && !vrate.EmailAllowed() { return nil, errHTTPTooManyRequestsLimitEmails.With(t) + } else if sms != "" && !vrate.SMSAllowed() { + return nil, errHTTPTooManyRequestsLimitSMS.With(t) + } else if call != "" && !vrate.CallAllowed() { + return nil, errHTTPTooManyRequestsLimitCalls.With(t) } if m.PollID != "" { m = newPollRequestMessage(t.ID, m.PollID) @@ -726,7 +730,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e if s.config.TwilioAccount != "" && sms != "" { go s.sendSMS(v, r, m, sms) } - if call != "" { + if s.config.TwilioAccount != "" && call != "" { go s.callPhone(v, r, m, call) } if s.config.UpstreamBaseURL != "" { diff --git a/server/server.yml b/server/server.yml index 9e515ec..fb4d1d9 100644 --- a/server/server.yml +++ b/server/server.yml @@ -224,11 +224,17 @@ # visitor-request-limit-exempt-hosts: "" # Rate limiting: Hard daily limit of messages per visitor and day. The limit is reset -# every day at midnight UTC. If the limit is not set (or set to zero), the request -# limit (see above) governs the upper limit. +# every day at midnight UTC. If the limit is not set (or set to zero), the request limit (see above) +# governs the upper limit. SMS and calls are only supported if the twilio-settings are properly configured. # # visitor-message-daily-limit: 0 +# Rate limiting: Daily limit of SMS and calls per visitor and day. The limit is reset every day +# at midnight UTC. SMS and calls are only supported if the twilio-settings are properly configured. +# +# visitor-sms-daily-limit: 10 +# visitor-call-daily-limit: 10 + # Rate limiting: Allowed emails per visitor: # - visitor-email-limit-burst is the initial bucket of emails each visitor has # - visitor-email-limit-replenish is the rate at which the bucket is refilled diff --git a/server/server_account.go b/server/server_account.go index 1b2c0ce..bdc4290 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -56,6 +56,8 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis Messages: limits.MessageLimit, MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()), Emails: limits.EmailLimit, + SMS: limits.SMSLimit, + Calls: limits.CallLimit, Reservations: limits.ReservationsLimit, AttachmentTotalSize: limits.AttachmentTotalSizeLimit, AttachmentFileSize: limits.AttachmentFileSizeLimit, @@ -67,6 +69,10 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis MessagesRemaining: stats.MessagesRemaining, Emails: stats.Emails, EmailsRemaining: stats.EmailsRemaining, + SMS: stats.SMS, + SMSRemaining: stats.SMSRemaining, + Calls: stats.Calls, + CallsRemaining: stats.CallsRemaining, Reservations: stats.Reservations, ReservationsRemaining: stats.ReservationsRemaining, AttachmentTotalSize: stats.AttachmentTotalSize, diff --git a/server/types.go b/server/types.go index 563cafb..ae6724f 100644 --- a/server/types.go +++ b/server/types.go @@ -287,6 +287,8 @@ type apiAccountLimits struct { Messages int64 `json:"messages"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"` Emails int64 `json:"emails"` + SMS int64 `json:"sms"` + Calls int64 `json:"calls"` Reservations int64 `json:"reservations"` AttachmentTotalSize int64 `json:"attachment_total_size"` AttachmentFileSize int64 `json:"attachment_file_size"` @@ -299,6 +301,10 @@ type apiAccountStats struct { MessagesRemaining int64 `json:"messages_remaining"` Emails int64 `json:"emails"` EmailsRemaining int64 `json:"emails_remaining"` + SMS int64 `json:"sms"` + SMSRemaining int64 `json:"sms_remaining"` + Calls int64 `json:"calls"` + CallsRemaining int64 `json:"calls_remaining"` Reservations int64 `json:"reservations"` ReservationsRemaining int64 `json:"reservations_remaining"` AttachmentTotalSize int64 `json:"attachment_total_size"` diff --git a/server/visitor.go b/server/visitor.go index 63a3ac6..4de51e6 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -56,6 +56,8 @@ type visitor struct { requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages) messagesLimiter *util.FixedLimiter // Rate limiter for messages emailsLimiter *util.RateLimiter // Rate limiter for emails + smsLimiter *util.FixedLimiter // Rate limiter for SMS + callsLimiter *util.FixedLimiter // Rate limiter for calls subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil @@ -79,6 +81,8 @@ type visitorLimits struct { EmailLimit int64 EmailLimitBurst int EmailLimitReplenish rate.Limit + SMSLimit int64 + CallLimit int64 ReservationsLimit int64 AttachmentTotalSizeLimit int64 AttachmentFileSizeLimit int64 @@ -91,6 +95,10 @@ type visitorStats struct { MessagesRemaining int64 Emails int64 EmailsRemaining int64 + SMS int64 + SMSRemaining int64 + Calls int64 + CallsRemaining int64 Reservations int64 ReservationsRemaining int64 AttachmentTotalSize int64 @@ -107,10 +115,12 @@ const ( ) func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { - var messages, emails int64 + var messages, emails, sms, calls int64 if user != nil { messages = user.Stats.Messages emails = user.Stats.Emails + sms = user.Stats.SMS + calls = user.Stats.Calls } v := &visitor{ config: conf, @@ -124,11 +134,13 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana requestLimiter: nil, // Set in resetLimiters messagesLimiter: nil, // Set in resetLimiters, may be nil emailsLimiter: nil, // Set in resetLimiters + smsLimiter: nil, // Set in resetLimiters, may be nil + callsLimiter: nil, // Set in resetLimiters, may be nil bandwidthLimiter: nil, // Set in resetLimiters accountLimiter: nil, // Set in resetLimiters, may be nil authLimiter: nil, // Set in resetLimiters, may be nil } - v.resetLimitersNoLock(messages, emails, false) + v.resetLimitersNoLock(messages, emails, sms, calls, false) return v } @@ -147,12 +159,22 @@ func (v *visitor) contextNoLock() log.Context { "visitor_messages": info.Stats.Messages, "visitor_messages_limit": info.Limits.MessageLimit, "visitor_messages_remaining": info.Stats.MessagesRemaining, - "visitor_emails": info.Stats.Emails, - "visitor_emails_limit": info.Limits.EmailLimit, - "visitor_emails_remaining": info.Stats.EmailsRemaining, "visitor_request_limiter_limit": v.requestLimiter.Limit(), "visitor_request_limiter_tokens": v.requestLimiter.Tokens(), } + if v.config.SMTPSenderFrom != "" { + fields["visitor_emails"] = info.Stats.Emails + fields["visitor_emails_limit"] = info.Limits.EmailLimit + fields["visitor_emails_remaining"] = info.Stats.EmailsRemaining + } + if v.config.TwilioAccount != "" { + fields["visitor_sms"] = info.Stats.SMS + fields["visitor_sms_limit"] = info.Limits.SMSLimit + fields["visitor_sms_remaining"] = info.Stats.SMSRemaining + fields["visitor_calls"] = info.Stats.Calls + fields["visitor_calls_limit"] = info.Limits.CallLimit + fields["visitor_calls_remaining"] = info.Stats.CallsRemaining + } if v.authLimiter != nil { fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit() fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens() @@ -216,6 +238,18 @@ func (v *visitor) EmailAllowed() bool { return v.emailsLimiter.Allow() } +func (v *visitor) SMSAllowed() bool { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + return v.smsLimiter.Allow() +} + +func (v *visitor) CallAllowed() bool { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + return v.callsLimiter.Allow() +} + func (v *visitor) SubscriptionAllowed() bool { v.mu.RLock() // limiters could be replaced! defer v.mu.RUnlock() @@ -296,6 +330,8 @@ func (v *visitor) Stats() *user.Stats { return &user.Stats{ Messages: v.messagesLimiter.Value(), Emails: v.emailsLimiter.Value(), + SMS: v.smsLimiter.Value(), + Calls: v.callsLimiter.Value(), } } @@ -304,6 +340,8 @@ func (v *visitor) ResetStats() { defer v.mu.RUnlock() v.emailsLimiter.Reset() v.messagesLimiter.Reset() + v.smsLimiter.Reset() + v.callsLimiter.Reset() } // User returns the visitor user, or nil if there is none @@ -334,11 +372,11 @@ func (v *visitor) SetUser(u *user.User) { shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver v.user = u // u may be nil! if shouldResetLimiters { - var messages, emails int64 + var messages, emails, sms, calls int64 if u != nil { - messages, emails = u.Stats.Messages, u.Stats.Emails + messages, emails, sms, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.SMS, u.Stats.Calls } - v.resetLimitersNoLock(messages, emails, true) + v.resetLimitersNoLock(messages, emails, sms, calls, true) } } @@ -353,11 +391,13 @@ func (v *visitor) MaybeUserID() string { return "" } -func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool) { +func (v *visitor) resetLimitersNoLock(messages, emails, sms, calls int64, enqueueUpdate bool) { limits := v.limitsNoLock() v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst) v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages) v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails) + v.smsLimiter = util.NewFixedLimiterWithValue(limits.SMSLimit, sms) + v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls) v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay) if v.user == nil { v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst) @@ -370,6 +410,8 @@ func (v *visitor) resetLimitersNoLock(messages, emails int64, enqueueUpdate bool go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{ Messages: messages, Emails: emails, + SMS: sms, + Calls: calls, }) } log.Fields(v.contextNoLock()).Debug("Rate limiters reset for visitor") // Must be after function, because contextNoLock() describes rate limiters @@ -398,6 +440,8 @@ func tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits { EmailLimit: tier.EmailLimit, EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax), EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit), + SMSLimit: tier.SMSLimit, + CallLimit: tier.CallLimit, ReservationsLimit: tier.ReservationLimit, AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit, AttachmentFileSizeLimit: tier.AttachmentFileSizeLimit, @@ -420,6 +464,8 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits { EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation! EmailLimitBurst: conf.VisitorEmailLimitBurst, EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish), + SMSLimit: int64(conf.VisitorSMSDailyLimit), + CallLimit: int64(conf.VisitorCallDailyLimit), ReservationsLimit: visitorDefaultReservationsLimit, AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit, AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit, @@ -465,12 +511,18 @@ func (v *visitor) Info() (*visitorInfo, error) { func (v *visitor) infoLightNoLock() *visitorInfo { messages := v.messagesLimiter.Value() emails := v.emailsLimiter.Value() + sms := v.smsLimiter.Value() + calls := v.callsLimiter.Value() limits := v.limitsNoLock() stats := &visitorStats{ Messages: messages, MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages), Emails: emails, EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails), + SMS: sms, + SMSRemaining: zeroIfNegative(limits.SMSLimit - sms), + Calls: calls, + CallsRemaining: zeroIfNegative(limits.CallLimit - calls), } return &visitorInfo{ Limits: limits, diff --git a/user/manager.go b/user/manager.go index b2898ae..3effd5c 100644 --- a/user/manager.go +++ b/user/manager.go @@ -55,6 +55,8 @@ const ( messages_limit INT NOT NULL, messages_expiry_duration INT NOT NULL, emails_limit INT NOT NULL, + sms_limit INT NOT NULL, + calls_limit INT NOT NULL, reservations_limit INT NOT NULL, attachment_file_size_limit INT NOT NULL, attachment_total_size_limit INT NOT NULL, @@ -76,6 +78,8 @@ const ( sync_topic TEXT NOT NULL, stats_messages INT NOT NULL DEFAULT (0), stats_emails INT NOT NULL DEFAULT (0), + stats_sms INT NOT NULL DEFAULT (0), + stats_calls INT NOT NULL DEFAULT (0), stripe_customer_id TEXT, stripe_subscription_id TEXT, stripe_subscription_status TEXT, @@ -123,26 +127,26 @@ const ( ` selectUserByIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.id = ? ` selectUserByNameQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE user = ? ` selectUserByTokenQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u JOIN user_token tk on u.id = tk.user_id LEFT JOIN tier t on t.id = u.tier_id WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) ` selectUserByStripeCustomerIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.stripe_customer_id = ? @@ -173,8 +177,8 @@ const ( updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` - updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ? WHERE id = ?` - updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0` + updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_sms = ?, stats_calls = ? WHERE id = ?` + updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_sms = 0, stats_calls = 0` updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?` deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?` deleteUserQuery = `DELETE FROM user WHERE user = ?` @@ -258,25 +262,25 @@ const ( ` insertTierQuery = ` - INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` updateTierQuery = ` UPDATE tier - SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ? + SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, sms_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ? WHERE code = ? ` selectTiersQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier ` selectTierByCodeQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier WHERE code = ? ` selectTierByPriceIDQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?) ` @@ -293,7 +297,7 @@ const ( // Schema management queries const ( - currentSchemaVersion = 3 + currentSchemaVersion = 4 insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)` updateSchemaVersion = `UPDATE schemaVersion SET version = ? WHERE id = 1` selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` @@ -391,12 +395,21 @@ const ( CREATE UNIQUE INDEX idx_tier_stripe_monthly_price_id ON tier (stripe_monthly_price_id); CREATE UNIQUE INDEX idx_tier_stripe_yearly_price_id ON tier (stripe_yearly_price_id); ` + + // 3 -> 4 + migrate3To4UpdateQueries = ` + ALTER TABLE tier ADD COLUMN sms_limit INT NOT NULL DEFAULT (0); + ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0); + ALTER TABLE user ADD COLUMN stats_sms INT NOT NULL DEFAULT (0); + ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0); + ` ) var ( migrations = map[int]func(db *sql.DB) error{ 1: migrateFrom1, 2: migrateFrom2, + 3: migrateFrom3, } ) @@ -700,9 +713,11 @@ func (a *Manager) writeUserStatsQueue() error { "user_id": userID, "messages_count": update.Messages, "emails_count": update.Emails, + "sms_count": update.SMS, + "calls_count": update.Calls, }). Trace("Updating stats for user %s", userID) - if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, userID); err != nil { + if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.SMS, update.Calls, userID); err != nil { return err } } @@ -911,12 +926,12 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() var id, username, hash, role, prefs, syncTopic string var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString - var messages, emails int64 + var messages, emails, sms, calls int64 var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 if !rows.Next() { return nil, ErrUserNotFound } - if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &sms, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -931,6 +946,8 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { Stats: &Stats{ Messages: messages, Emails: emails, + SMS: sms, + Calls: calls, }, Billing: &Billing{ StripeCustomerID: stripeCustomerID.String, // May be empty @@ -1259,7 +1276,7 @@ func (a *Manager) AddTier(tier *Tier) error { if tier.ID == "" { tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength) } - if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil { + if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.SMSLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil { return err } return nil @@ -1267,7 +1284,7 @@ func (a *Manager) AddTier(tier *Tier) error { // UpdateTier updates a tier's properties in the database func (a *Manager) UpdateTier(tier *Tier) error { - if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil { + if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.SMSLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil { return err } return nil @@ -1336,11 +1353,11 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) { func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { var id, code, name string var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString - var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 + var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 if !rows.Next() { return nil, ErrTierNotFound } - if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &smsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -1353,6 +1370,8 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { MessageLimit: messagesLimit.Int64, MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, EmailLimit: emailsLimit.Int64, + SMSLimit: smsLimit.Int64, + CallLimit: callsLimit.Int64, ReservationLimit: reservationsLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, @@ -1495,6 +1514,22 @@ func migrateFrom2(db *sql.DB) error { return tx.Commit() } +func migrateFrom3(db *sql.DB) error { + log.Tag(tag).Info("Migrating user database schema: from 3 to 4") + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(migrate3To4UpdateQueries); err != nil { + return err + } + if _, err := tx.Exec(updateSchemaVersion, 4); err != nil { + return err + } + return tx.Commit() +} + func nullString(s string) sql.NullString { if s == "" { return sql.NullString{} diff --git a/user/types.go b/user/types.go index 2486f11..6340229 100644 --- a/user/types.go +++ b/user/types.go @@ -86,6 +86,8 @@ type Tier struct { MessageLimit int64 // Daily message limit MessageExpiryDuration time.Duration // Cache duration for messages EmailLimit int64 // Daily email limit + SMSLimit int64 // Daily SMS limit + CallLimit int64 // Daily phone call limit ReservationLimit int64 // Number of topic reservations allowed by user AttachmentFileSizeLimit int64 // Max file size per file (bytes) AttachmentTotalSizeLimit int64 // Total file size for all files of this user (bytes) @@ -131,6 +133,8 @@ type NotificationPrefs struct { type Stats struct { Messages int64 Emails int64 + SMS int64 + Calls int64 } // Billing is a struct holding a user's billing information From eb0805a4706723aa0011918774de62e4422a040f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 7 May 2023 22:28:07 -0400 Subject: [PATCH 05/97] Update web app with SMS and calls stuff --- docs/releases.md | 4 ++ server/server.go | 2 + server/server_payments.go | 4 ++ server/server_twilio.go | 11 +-- server/server_twilio_test.go | 96 +++++++++++++++++++++++++ server/types.go | 2 + user/manager.go | 14 ++-- web/public/config.js | 4 +- web/public/static/langs/en.json | 18 +++++ web/src/app/utils.js | 6 +- web/src/components/Account.js | 87 ++++++++++++++++------ web/src/components/PublishDialog.js | 54 ++++++++++++++ web/src/components/SubscriptionPopup.js | 4 +- web/src/components/UpgradeDialog.js | 7 +- 14 files changed, 274 insertions(+), 39 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 4b24085..72cc39e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1180,6 +1180,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## ntfy server v2.5.0 (UNRELEASED) +**Features:** + +* Support for SMS and voice calls using Twilio (no ticket) + **Bug fixes + maintenance:** * Removed old ntfy website from ntfy entirely (no ticket) diff --git a/server/server.go b/server/server.go index 8c2f83c..79aa808 100644 --- a/server/server.go +++ b/server/server.go @@ -529,6 +529,8 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi EnableLogin: s.config.EnableLogin, EnableSignup: s.config.EnableSignup, EnablePayments: s.config.StripeSecretKey != "", + EnableSMS: s.config.TwilioAccount != "", + EnableCalls: s.config.TwilioAccount != "", EnableReservations: s.config.EnableReservations, BillingContact: s.config.BillingContact, DisallowedTopics: s.config.DisallowedTopics, diff --git a/server/server_payments.go b/server/server_payments.go index cb58596..bd91338 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -68,6 +68,8 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ Messages: freeTier.MessageLimit, MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()), Emails: freeTier.EmailLimit, + SMS: freeTier.SMSLimit, + Calls: freeTier.CallLimit, Reservations: freeTier.ReservationsLimit, AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit, AttachmentFileSize: freeTier.AttachmentFileSizeLimit, @@ -96,6 +98,8 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ Messages: tier.MessageLimit, MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()), Emails: tier.EmailLimit, + SMS: tier.SMSLimit, + Calls: tier.CallLimit, Reservations: tier.ReservationLimit, AttachmentTotalSize: tier.AttachmentTotalSizeLimit, AttachmentFileSize: tier.AttachmentFileSizeLimit, diff --git a/server/server_twilio.go b/server/server_twilio.go index fc5fb65..1bd1111 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/prometheus/client_golang/prometheus" "heckel.io/ntfy/log" + "heckel.io/ntfy/user" "heckel.io/ntfy/util" "io" "net/http" @@ -32,7 +33,7 @@ const ( ) func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { - body := fmt.Sprintf("%s\n\n--\n%s", m.Message, s.messageFooter(m)) + body := fmt.Sprintf("%s\n\n--\n%s", m.Message, s.messageFooter(v.User(), m)) data := url.Values{} data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) @@ -41,7 +42,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { } func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { - body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(m))) + body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(v.User(), m))) data := url.Values{} data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) @@ -97,11 +98,11 @@ func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) return string(response), nil } -func (s *Server) messageFooter(m *message) string { +func (s *Server) messageFooter(u *user.User, m *message) string { // u may be nil! topicURL := s.config.BaseURL + "/" + m.Topic sender := m.Sender.String() - if m.User != "" { - sender = fmt.Sprintf("%s (%s)", m.User, m.Sender) + if u != nil { + sender = fmt.Sprintf("%s (%s)", u.Name, m.Sender) } return fmt.Sprintf(twilioMessageFooterFormat, sender, util.ShortTopicURL(topicURL)) } diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index d99f9b6..913a520 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -2,6 +2,8 @@ package server import ( "github.com/stretchr/testify/require" + "heckel.io/ntfy/user" + "heckel.io/ntfy/util" "io" "net/http" "net/http/httptest" @@ -27,6 +29,7 @@ func TestServer_Twilio_SMS(t *testing.T) { c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" + c.VisitorSMSDailyLimit = 1 s := newTestServer(t, c) response := request(t, s, "POST", "/mytopic", "test", map[string]string{ @@ -38,6 +41,56 @@ func TestServer_Twilio_SMS(t *testing.T) { }) } +func TestServer_Twilio_SMS_With_User(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Messages.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "Body=test%0A%0A--%0AThis+message+was+sent+by+phil+%289.9.9.9%29+via+ntfy.sh%2Fmytopic&From=%2B1234567890&To=%2B11122233344", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfigWithAuthFile(t) + c.BaseURL = "https://ntfy.sh" + c.TwilioBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + SMSLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + + // Do request with user + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + "SMS": "+11122233344", + }) + require.Equal(t, "test", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) + + // Second one should fail due to rate limits + response = request(t, s, "POST", "/mytopic", "test", map[string]string{ + "Authorization": util.BasicAuth("phil", "phil"), + "SMS": "+11122233344", + }) + require.Equal(t, 42910, toHTTPError(t, response.Body.String()).Code) +} + func TestServer_Twilio_Call(t *testing.T) { var called atomic.Bool twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -55,6 +108,7 @@ func TestServer_Twilio_Call(t *testing.T) { c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" + c.VisitorCallDailyLimit = 1 s := newTestServer(t, c) body := `this message has @@ -69,6 +123,48 @@ and "quotes and other 'quotes` }) } +func TestServer_Twilio_Call_With_User(t *testing.T) { + var called atomic.Bool + twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EYou+have+a+message+from+notify+on+topic+mytopic.+Message%3A%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3Ehi+there%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EEnd+message.%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EThis+message+was+sent+by+phil+%289.9.9.9%29+via+127.0.0.1%3A12345%2Fmytopic%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%3C%2FResponse%3E", string(body)) + called.Store(true) + })) + defer twilioServer.Close() + + c := newTestConfigWithAuthFile(t) + c.TwilioBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + + // Do the thing + response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "+11122233344", + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { c := newTestConfig(t) c.TwilioBaseURL = "https://127.0.0.1" diff --git a/server/types.go b/server/types.go index ae6724f..98ab4e2 100644 --- a/server/types.go +++ b/server/types.go @@ -351,6 +351,8 @@ type apiConfigResponse struct { EnableLogin bool `json:"enable_login"` EnableSignup bool `json:"enable_signup"` EnablePayments bool `json:"enable_payments"` + EnableSMS bool `json:"enable_sms"` + EnableCalls bool `json:"enable_calls"` EnableReservations bool `json:"enable_reservations"` BillingContact string `json:"billing_contact"` DisallowedTopics []string `json:"disallowed_topics"` diff --git a/user/manager.go b/user/manager.go index 3effd5c..017996c 100644 --- a/user/manager.go +++ b/user/manager.go @@ -127,26 +127,26 @@ const ( ` selectUserByIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.id = ? ` selectUserByNameQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE user = ? ` selectUserByTokenQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u JOIN user_token tk on u.id = tk.user_id LEFT JOIN tier t on t.id = u.tier_id WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) ` selectUserByStripeCustomerIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.stripe_customer_id = ? @@ -927,11 +927,11 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { var id, username, hash, role, prefs, syncTopic string var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString var messages, emails, sms, calls int64 - var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 + var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 if !rows.Next() { return nil, ErrUserNotFound } - if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &sms, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &sms, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &smsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -971,6 +971,8 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { MessageLimit: messagesLimit.Int64, MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, EmailLimit: emailsLimit.Int64, + SMSLimit: smsLimit.Int64, + CallLimit: callsLimit.Int64, ReservationLimit: reservationsLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, diff --git a/web/public/config.js b/web/public/config.js index 30da691..f5a5759 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -6,12 +6,14 @@ // During web development, you may change values here for rapid testing. var config = { - base_url: window.location.origin, // Change to test against a different server + base_url: "http://127.0.0.1:2586",// FIXME window.location.origin, // Change to test against a different server app_root: "/app", enable_login: true, enable_signup: true, enable_payments: true, enable_reservations: true, + enable_sms: true, + enable_calls: true, billing_contact: "", disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"] }; diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 8760eb3..600994b 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -127,6 +127,12 @@ "publish_dialog_email_label": "Email", "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "publish_dialog_email_reset": "Remove email forward", + "publish_dialog_sms_label": "SMS", + "publish_dialog_sms_placeholder": "Phone number to send SMS to, e.g. +12223334444", + "publish_dialog_sms_reset": "Remove SMS message", + "publish_dialog_call_label": "Phone call", + "publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444", + "publish_dialog_call_reset": "Remove phone call", "publish_dialog_attach_label": "Attachment URL", "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", "publish_dialog_attach_reset": "Remove attachment URL", @@ -138,6 +144,8 @@ "publish_dialog_other_features": "Other features:", "publish_dialog_chip_click_label": "Click URL", "publish_dialog_chip_email_label": "Forward to email", + "publish_dialog_chip_sms_label": "Send SMS", + "publish_dialog_chip_call_label": "Phone call", "publish_dialog_chip_attach_url_label": "Attach file by URL", "publish_dialog_chip_attach_file_label": "Attach local file", "publish_dialog_chip_delay_label": "Delay delivery", @@ -203,6 +211,10 @@ "account_basics_tier_manage_billing_button": "Manage billing", "account_usage_messages_title": "Published messages", "account_usage_emails_title": "Emails sent", + "account_usage_sms_title": "SMS sent", + "account_usage_sms_none": "No SMS can be sent with this account", + "account_usage_calls_title": "Phone calls made", + "account_usage_calls_none": "No phone calls can be made with this account", "account_usage_reservations_title": "Reserved topics", "account_usage_reservations_none": "No reserved topics for this account", "account_usage_attachment_storage_title": "Attachment storage", @@ -232,6 +244,12 @@ "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email", "account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails", + "account_upgrade_dialog_tier_features_sms_one": "{{sms}} daily SMS", + "account_upgrade_dialog_tier_features_sms_other": "{{sms}} daily SMS", + "account_upgrade_dialog_tier_features_no_sms": "No daily SMS", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} daily phone calls", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls", + "account_upgrade_dialog_tier_features_no_calls": "No daily phone calls", "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file", "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage", "account_upgrade_dialog_tier_price_per_month": "month", diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 6eb4ac5..25b4a45 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -206,10 +206,12 @@ export const formatBytes = (bytes, decimals = 2) => { } export const formatNumber = (n) => { - if (n % 1000 === 0) { + if (n === 0) { + return n; + } else if (n % 1000 === 0) { return `${n/1000}k`; } - return n; + return n.toLocaleString(); } export const formatPrice = (n) => { diff --git a/web/src/components/Account.js b/web/src/components/Account.js index e5b6007..dc80bab 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -51,6 +51,7 @@ import {ContentCopy, Public} from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; import DialogContentText from "@mui/material/DialogContentText"; import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; +import {ProChip} from "./SubscriptionPopup"; const Account = () => { if (!session.exists()) { @@ -337,23 +338,18 @@ const Stats = () => { {t("account_usage_title")} - - {(account.role === Role.ADMIN || account.limits.reservations > 0) && - <> -
- {account.stats.reservations} - {account.role === Role.USER ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")} -
- 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} - /> - - } - {account.role === Role.USER && account.limits.reservations === 0 && - {t("account_usage_reservations_none")} - } -
+ {(account.role === Role.ADMIN || account.limits.reservations > 0) && + +
+ {account.stats.reservations.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.reservations.toLocaleString() }) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} + /> +
+ } {t("account_usage_messages_title")} @@ -361,8 +357,8 @@ const Stats = () => { }>
- {account.stats.messages} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")} + {account.stats.messages.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages.toLocaleString() }) : t("account_usage_unlimited")}
{ }>
- {account.stats.emails} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")} + {account.stats.emails.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")}
+ {(account.role === Role.ADMIN || account.limits.sms > 0) && + + {t("account_usage_sms_title")} + + + }> +
+ {account.stats.sms.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.sms.toLocaleString() }) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.sms, account.limits.sms) : 100} + /> +
+ } + {(account.role === Role.ADMIN || account.limits.calls > 0) && + + {t("account_usage_calls_title")} + + + }> +
+ {account.stats.calls.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.calls.toLocaleString() }) : t("account_usage_unlimited")} +
+ 0 ? normalize(account.stats.calls, account.limits.calls) : 100} + /> +
+ } { value={account.role === Role.USER ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100} /> + {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && + {t("account_usage_reservations_title")}{config.enable_payments && }}> + {t("account_usage_reservations_none")} + + } + {config.enable_sms && account.role === Role.USER && account.limits.sms === 0 && + {t("account_usage_sms_title")}{config.enable_payments && }}> + {t("account_usage_sms_none")} + + } + {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && + {t("account_usage_calls_title")}{config.enable_payments && }}> + {t("account_usage_calls_none")} + + }
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index bdf6fb6..c410f19 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -45,6 +45,8 @@ const PublishDialog = (props) => { const [filename, setFilename] = useState(""); const [filenameEdited, setFilenameEdited] = useState(false); const [email, setEmail] = useState(""); + const [sms, setSms] = useState(""); + const [call, setCall] = useState(""); const [delay, setDelay] = useState(""); const [publishAnother, setPublishAnother] = useState(false); @@ -52,6 +54,8 @@ const PublishDialog = (props) => { const [showClickUrl, setShowClickUrl] = useState(false); const [showAttachUrl, setShowAttachUrl] = useState(false); const [showEmail, setShowEmail] = useState(false); + const [showSms, setShowSms] = useState(false); + const [showCall, setShowCall] = useState(false); const [showDelay, setShowDelay] = useState(false); const showAttachFile = !!attachFile && !showAttachUrl; @@ -124,6 +128,12 @@ const PublishDialog = (props) => { if (email.trim()) { url.searchParams.append("email", email.trim()); } + if (sms.trim()) { + url.searchParams.append("sms", sms.trim()); + } + if (call.trim()) { + url.searchParams.append("call", call.trim()); + } if (delay.trim()) { url.searchParams.append("delay", delay.trim()); } @@ -406,6 +416,48 @@ const PublishDialog = (props) => { /> } + {showSms && + { + setSms(""); + setShowSms(false); + }}> + setSms(ev.target.value)} + disabled={disabled} + type="tel" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_sms_label") + }} + /> + + } + {showCall && + { + setCall(""); + setShowCall(false); + }}> + setCall(ev.target.value)} + disabled={disabled} + type="tel" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_call_label") + }} + /> + + } {showAttachUrl && { setAttachUrl(""); @@ -510,6 +562,8 @@ const PublishDialog = (props) => {
{!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showSms && setShowSms(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {!showCall && setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} {!showDelay && setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} diff --git a/web/src/components/SubscriptionPopup.js b/web/src/components/SubscriptionPopup.js index 7655605..024b6f2 100644 --- a/web/src/components/SubscriptionPopup.js +++ b/web/src/components/SubscriptionPopup.js @@ -277,14 +277,14 @@ const LimitReachedChip = () => { ); }; -const ProChip = () => { +export const ProChip = () => { const { t } = useTranslation(); return ( ); }; diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js index c62560a..c4d665e 100644 --- a/web/src/components/UpgradeDialog.js +++ b/web/src/components/UpgradeDialog.js @@ -298,11 +298,14 @@ const TierCard = (props) => {
{tier.limits.reservations > 0 && {t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}} - {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} {t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })} {t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })} + {tier.limits.sms > 0 && {t("account_upgrade_dialog_tier_features_sms", { sms: formatNumber(tier.limits.sms), count: tier.limits.sms })}} + {tier.limits.calls > 0 && {t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}} {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })} - {t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })} + {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} + {tier.limits.sms === 0 && {t("account_upgrade_dialog_tier_features_no_sms")}} + {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}} {tier.prices && props.interval === SubscriptionInterval.MONTH && From 559f09e7be573c3b2831e61a30229063b7742bce Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 9 May 2023 09:33:01 -0400 Subject: [PATCH 06/97] WIP Docs --- docs/publish.md | 231 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) diff --git a/docs/publish.md b/docs/publish.md index b046bd2..1f52cf3 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2695,6 +2695,237 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
Publishing a message via e-mail
+## Text message (SMS) +_Supported on:_ :material-android: :material-apple: :material-firefox: + +You can forward messages as text message (SMS) by specifying a phone number a header. Similar to email notifications, +this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have the ntfy app +installed on their phone. + +To forward a message as an SMS, pass a phone number in the `X-SMS` header (or its alias: `SMS`), prefixed with a plus sign +and the country code, e.g. `+12223334444`. + +On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans. + +=== "Command line (curl)" + ``` + curl \ + -H "SMS: +12223334444" \ + -d "Your garage seems to be on fire 🔥. You should probably check that out, and call 0118 999 881 999 119 725 3." \ + ntfy.sh/alerts + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --email=phil@example.com \ + --tags=warning,skull,backup-host,ssh-login \ + --priority=high \ + alerts "Unknown login from 5.31.23.83 to backups.example.com" + ``` + +=== "HTTP" + ``` http + POST /alerts HTTP/1.1 + Host: ntfy.sh + Email: phil@example.com + Tags: warning,skull,backup-host,ssh-login + Priority: high + + Unknown login from 5.31.23.83 to backups.example.com + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/alerts', { + method: 'POST', + body: "Unknown login from 5.31.23.83 to backups.example.com", + headers: { + 'Email': 'phil@example.com', + 'Tags': 'warning,skull,backup-host,ssh-login', + 'Priority': 'high' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts", + strings.NewReader("Unknown login from 5.31.23.83 to backups.example.com")) + req.Header.Set("Email", "phil@example.com") + req.Header.Set("Tags", "warning,skull,backup-host,ssh-login") + req.Header.Set("Priority", "high") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/alerts" + Headers = @{ + Title = "Low disk space alert" + Priority = "high" + Tags = "warning,skull,backup-host,ssh-login") + Email = "phil@example.com" + } + Body = "Unknown login from 5.31.23.83 to backups.example.com" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/alerts", + data="Unknown login from 5.31.23.83 to backups.example.com", + headers={ + "Email": "phil@example.com", + "Tags": "warning,skull,backup-host,ssh-login", + "Priority": "high" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Email: phil@example.com\r\n" . + "Tags: warning,skull,backup-host,ssh-login\r\n" . + "Priority: high", + 'content' => 'Unknown login from 5.31.23.83 to backups.example.com' + ] + ])); + ``` + +Here's what that looks like in Google Mail: + +
+ ![e-mail notification](static/img/screenshot-email.png){ width=600 } +
E-mail notification
+
+ + +## Phone calls +_Supported on:_ :material-android: :material-apple: :material-firefox: + +You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that +you'd like to persist longer, or to blast-notify yourself on all possible channels. + +Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`). +Only one e-mail address is supported. + +Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the +default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of +that, your IP address appears in the e-mail body. This is to prevent abuse. + +=== "Command line (curl)" + ``` + curl \ + -H "Email: phil@example.com" \ + -H "Tags: warning,skull,backup-host,ssh-login" \ + -H "Priority: high" \ + -d "Unknown login from 5.31.23.83 to backups.example.com" \ + ntfy.sh/alerts + curl -H "Email: phil@example.com" -d "You've Got Mail" + curl -d "You've Got Mail" "ntfy.sh/alerts?email=phil@example.com" + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --email=phil@example.com \ + --tags=warning,skull,backup-host,ssh-login \ + --priority=high \ + alerts "Unknown login from 5.31.23.83 to backups.example.com" + ``` + +=== "HTTP" + ``` http + POST /alerts HTTP/1.1 + Host: ntfy.sh + Email: phil@example.com + Tags: warning,skull,backup-host,ssh-login + Priority: high + + Unknown login from 5.31.23.83 to backups.example.com + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/alerts', { + method: 'POST', + body: "Unknown login from 5.31.23.83 to backups.example.com", + headers: { + 'Email': 'phil@example.com', + 'Tags': 'warning,skull,backup-host,ssh-login', + 'Priority': 'high' + } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts", + strings.NewReader("Unknown login from 5.31.23.83 to backups.example.com")) + req.Header.Set("Email", "phil@example.com") + req.Header.Set("Tags", "warning,skull,backup-host,ssh-login") + req.Header.Set("Priority", "high") + http.DefaultClient.Do(req) + ``` + +=== "PowerShell" + ``` powershell + $Request = @{ + Method = "POST" + URI = "https://ntfy.sh/alerts" + Headers = @{ + Title = "Low disk space alert" + Priority = "high" + Tags = "warning,skull,backup-host,ssh-login") + Email = "phil@example.com" + } + Body = "Unknown login from 5.31.23.83 to backups.example.com" + } + Invoke-RestMethod @Request + ``` + +=== "Python" + ``` python + requests.post("https://ntfy.sh/alerts", + data="Unknown login from 5.31.23.83 to backups.example.com", + headers={ + "Email": "phil@example.com", + "Tags": "warning,skull,backup-host,ssh-login", + "Priority": "high" + }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Email: phil@example.com\r\n" . + "Tags: warning,skull,backup-host,ssh-login\r\n" . + "Priority: high", + 'content' => 'Unknown login from 5.31.23.83 to backups.example.com' + ] + ])); + ``` + +Here's what that looks like in Google Mail: + +
+ ![e-mail notification](static/img/screenshot-email.png){ width=600 } +
E-mail notification
+
+ + ## Authentication Depending on whether the server is configured to support [access control](config.md#access-control), some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. From d4767caf304217f96d46798f7bb21d9e67bf744a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 11 May 2023 13:50:10 -0400 Subject: [PATCH 07/97] Verify --- cmd/serve.go | 3 ++ server/config.go | 8 +++- server/server.go | 5 +++ server/server.yml | 1 + server/server_account.go | 87 ++++++++++++++++++++++++++++++++++++ server/server_twilio.go | 74 +++++++++++++++++++++++++++--- server/server_twilio_test.go | 12 ++--- server/types.go | 39 +++++++++++----- user/manager.go | 70 +++++++++++++++++++++++++++++ user/types.go | 6 +++ 10 files changed, 279 insertions(+), 26 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 6c72975..42b7233 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -74,6 +74,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for SMS and calling, e.g. AC123..."}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-from-number", Aliases: []string{"twilio_from_number"}, EnvVars: []string{"NTFY_TWILIO_FROM_NUMBER"}, Usage: "Twilio number to use for outgoing calls and text messages"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), @@ -159,6 +160,7 @@ func execServe(c *cli.Context) error { twilioAccount := c.String("twilio-account") twilioAuthToken := c.String("twilio-auth-token") twilioFromNumber := c.String("twilio-from-number") + twilioVerifyService := c.String("twilio-verify-service") totalTopicLimit := c.Int("global-topic-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") visitorSubscriberRateLimiting := c.Bool("visitor-subscriber-rate-limiting") @@ -323,6 +325,7 @@ func execServe(c *cli.Context) error { conf.TwilioAccount = twilioAccount conf.TwilioAuthToken = twilioAuthToken conf.TwilioFromNumber = twilioFromNumber + conf.TwilioVerifyService = twilioVerifyService conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit diff --git a/server/config.go b/server/config.go index b6d57d9..352d91f 100644 --- a/server/config.go +++ b/server/config.go @@ -107,10 +107,12 @@ type Config struct { SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string - TwilioBaseURL string + TwilioMessagingBaseURL string TwilioAccount string TwilioAuthToken string TwilioFromNumber string + TwilioVerifyBaseURL string + TwilioVerifyService string MetricsEnable bool MetricsListenHTTP string ProfileListenHTTP string @@ -191,10 +193,12 @@ func NewConfig() *Config { SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", - TwilioBaseURL: "https://api.twilio.com", // Override for tests + TwilioMessagingBaseURL: "https://api.twilio.com", // Override for tests TwilioAccount: "", TwilioAuthToken: "", TwilioFromNumber: "", + TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests + TwilioVerifyService: "", MessageLimit: DefaultMessageLengthLimit, MinDelay: DefaultMinDelay, MaxDelay: DefaultMaxDelay, diff --git a/server/server.go b/server/server.go index 79aa808..056e0a6 100644 --- a/server/server.go +++ b/server/server.go @@ -88,6 +88,7 @@ var ( apiAccountSettingsPath = "/v1/account/settings" apiAccountSubscriptionPath = "/v1/account/subscription" apiAccountReservationPath = "/v1/account/reservation" + apiAccountPhonePath = "/v1/account/phone" apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -450,6 +451,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath { return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe! + } else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath { + return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberAdd))(w, r, v) + } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPhonePath { + return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberVerify))(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath { return s.handleStats(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath { diff --git a/server/server.yml b/server/server.yml index fb4d1d9..f11ad36 100644 --- a/server/server.yml +++ b/server/server.yml @@ -149,6 +149,7 @@ # twilio-account: # twilio-auth-token: # twilio-from-number: +# twilio-verify-service: # Interval in which keepalive messages are sent to the client. This is to prevent # intermediaries closing the connection for inactivity. diff --git a/server/server_account.go b/server/server_account.go index bdc4290..cb3a52e 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -144,6 +144,19 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis }) } } + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + if err != nil { + return err + } + if len(phoneNumbers) > 0 { + response.PhoneNumbers = make([]*apiAccountPhoneNumberResponse, 0) + for _, p := range phoneNumbers { + response.PhoneNumbers = append(response.PhoneNumbers, &apiAccountPhoneNumberResponse{ + Number: p.Number, + Verified: p.Verified, + }) + } + } } else { response.Username = user.Everyone response.Role = string(user.RoleAnonymous) @@ -517,6 +530,80 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi return nil } +func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if !phoneNumberRegex.MatchString(req.Number) { + return errHTTPBadRequestPhoneNumberInvalid + } + // Check user is allowed to add phone numbers + if u == nil || (u.IsUser() && u.Tier == nil) { + return errHTTPUnauthorized + } else if u.IsUser() && u.Tier.SMSLimit == 0 && u.Tier.CallLimit == 0 { + return errHTTPUnauthorized + } + // Actually add the unverified number, and send verification + logvr(v, r). + Tag(tagAccount). + Fields(log.Context{ + "number": req.Number, + }). + Debug("Adding phone number, and sending verification") + if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil { + return err + } + if err := s.verifyPhone(v, r, req.Number); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if !phoneNumberRegex.MatchString(req.Number) { + return errHTTPBadRequestPhoneNumberInvalid + } + // Check user is allowed to add phone numbers + if u == nil { + return errHTTPUnauthorized + } + // Get phone numbers, and check if it's in the list + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + if err != nil { + return err + } + found := false + for _, phoneNumber := range phoneNumbers { + if phoneNumber.Number == req.Number && phoneNumber.Verified { + found = true + break + } + } + if !found { + return errHTTPBadRequestPhoneNumberInvalid + } + if err := s.checkVerifyPhone(v, r, req.Number, req.Code); err != nil { + return err + } + logvr(v, r). + Tag(tagAccount). + Fields(log.Context{ + "number": req.Number, + }). + Debug("Marking phone number as verified") + if err := s.userManager.MarkPhoneNumberVerified(u.ID, req.Number); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + // publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic func (s *Server) publishSyncEventAsync(v *visitor) { go func() { diff --git a/server/server_twilio.go b/server/server_twilio.go index 1bd1111..2f58773 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -38,7 +38,7 @@ func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) data.Set("Body", body) - s.performTwilioRequest(v, r, m, metricSMSSentSuccess, metricSMSSentFailure, twilioMessageEndpoint, to, body, data) + s.twilioMessagingRequest(v, r, m, metricSMSSentSuccess, metricSMSSentFailure, twilioMessageEndpoint, to, body, data) } func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { @@ -47,10 +47,72 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) data.Set("Twiml", body) - s.performTwilioRequest(v, r, m, metricCallsMadeSuccess, metricCallsMadeFailure, twilioCallEndpoint, to, body, data) + s.twilioMessagingRequest(v, r, m, metricCallsMadeSuccess, metricCallsMadeFailure, twilioCallEndpoint, to, body, data) } -func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, msuccess, mfailure prometheus.Counter, endpoint, to, body string, data url.Values) { +func (s *Server) verifyPhone(v *visitor, r *http.Request, phoneNumber string) error { + logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Sending phone verification") + data := url.Values{} + data.Set("To", phoneNumber) + data.Set("Channel", "sms") + requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService) + req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + response, err := io.ReadAll(resp.Body) + ev := logvr(v, r).Tag(tagTwilio) + if err != nil { + ev.Err(err).Warn("Error sending Twilio phone verification request") + return err + } + if ev.IsTrace() { + ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response") + } else if ev.IsDebug() { + ev.Debug("Received successful Twilio phone verification response") + } + return nil +} + +func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code string) error { + logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification") + data := url.Values{} + data.Set("To", phoneNumber) + data.Set("Code", code) + requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioAccount) + req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) + if err != nil { + return err + } + req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } else if resp.StatusCode != http.StatusOK { + return + } + response, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + ev := logvr(v, r).Tag(tagTwilio) + if ev.IsTrace() { + ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response") + } else if ev.IsDebug() { + ev.Debug("Received successful Twilio phone verification response") + } + return nil +} + +func (s *Server) twilioMessagingRequest(v *visitor, r *http.Request, m *message, msuccess, mfailure prometheus.Counter, endpoint, to, body string, data url.Values) { logContext := log.Context{ "twilio_from": s.config.TwilioFromNumber, "twilio_to": to, @@ -61,7 +123,7 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, m } else if ev.IsDebug() { ev.Debug("Sending Twilio request") } - response, err := s.performTwilioRequestInternal(endpoint, data) + response, err := s.performTwilioMessagingRequestInternal(endpoint, data) if err != nil { ev. Field("twilio_body", body). @@ -79,8 +141,8 @@ func (s *Server) performTwilioRequest(v *visitor, r *http.Request, m *message, m minc(msuccess) } -func (s *Server) performTwilioRequestInternal(endpoint string, data url.Values) (string, error) { - requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/%s", s.config.TwilioBaseURL, s.config.TwilioAccount, endpoint) +func (s *Server) performTwilioMessagingRequestInternal(endpoint string, data url.Values) (string, error) { + requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/%s", s.config.TwilioMessagingBaseURL, s.config.TwilioAccount, endpoint) req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) if err != nil { return "", err diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 913a520..133138f 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -25,7 +25,7 @@ func TestServer_Twilio_SMS(t *testing.T) { c := newTestConfig(t) c.BaseURL = "https://ntfy.sh" - c.TwilioBaseURL = twilioServer.URL + c.TwilioMessagingBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -58,7 +58,7 @@ func TestServer_Twilio_SMS_With_User(t *testing.T) { c := newTestConfigWithAuthFile(t) c.BaseURL = "https://ntfy.sh" - c.TwilioBaseURL = twilioServer.URL + c.TwilioMessagingBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -104,7 +104,7 @@ func TestServer_Twilio_Call(t *testing.T) { defer twilioServer.Close() c := newTestConfig(t) - c.TwilioBaseURL = twilioServer.URL + c.TwilioMessagingBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -139,7 +139,7 @@ func TestServer_Twilio_Call_With_User(t *testing.T) { defer twilioServer.Close() c := newTestConfigWithAuthFile(t) - c.TwilioBaseURL = twilioServer.URL + c.TwilioMessagingBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -167,7 +167,7 @@ func TestServer_Twilio_Call_With_User(t *testing.T) { func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { c := newTestConfig(t) - c.TwilioBaseURL = "https://127.0.0.1" + c.TwilioMessagingBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -181,7 +181,7 @@ func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { func TestServer_Twilio_SMS_InvalidNumber(t *testing.T) { c := newTestConfig(t) - c.TwilioBaseURL = "https://127.0.0.1" + c.TwilioMessagingBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" diff --git a/server/types.go b/server/types.go index 98ab4e2..1762294 100644 --- a/server/types.go +++ b/server/types.go @@ -277,6 +277,16 @@ type apiAccountTokenResponse struct { Expires int64 `json:"expires,omitempty"` // Unix timestamp } +type apiAccountPhoneNumberRequest struct { + Number string `json:"number"` + Code string `json:"code,omitempty"` // Only supplied in "verify" call +} + +type apiAccountPhoneNumberResponse struct { + Number string `json:"number"` + Verified bool `json:"verified"` +} + type apiAccountTier struct { Code string `json:"code"` Name string `json:"name"` @@ -326,18 +336,19 @@ type apiAccountBilling struct { } type apiAccountResponse struct { - Username string `json:"username"` - Role string `json:"role,omitempty"` - SyncTopic string `json:"sync_topic,omitempty"` - Language string `json:"language,omitempty"` - Notification *user.NotificationPrefs `json:"notification,omitempty"` - Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` - Reservations []*apiAccountReservation `json:"reservations,omitempty"` - Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` - Tier *apiAccountTier `json:"tier,omitempty"` - Limits *apiAccountLimits `json:"limits,omitempty"` - Stats *apiAccountStats `json:"stats,omitempty"` - Billing *apiAccountBilling `json:"billing,omitempty"` + Username string `json:"username"` + Role string `json:"role,omitempty"` + SyncTopic string `json:"sync_topic,omitempty"` + Language string `json:"language,omitempty"` + Notification *user.NotificationPrefs `json:"notification,omitempty"` + Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` + Reservations []*apiAccountReservation `json:"reservations,omitempty"` + Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` + PhoneNumbers []*apiAccountPhoneNumberResponse `json:"phone_numbers,omitempty"` + Tier *apiAccountTier `json:"tier,omitempty"` + Limits *apiAccountLimits `json:"limits,omitempty"` + Stats *apiAccountStats `json:"stats,omitempty"` + Billing *apiAccountBilling `json:"billing,omitempty"` } type apiAccountReservationRequest struct { @@ -419,3 +430,7 @@ type apiStripeSubscriptionDeletedEvent struct { ID string `json:"id"` Customer string `json:"customer"` } + +type apiTwilioVerifyResponse struct { + Status string `json:"status"` +} diff --git a/user/manager.go b/user/manager.go index 017996c..824622b 100644 --- a/user/manager.go +++ b/user/manager.go @@ -113,6 +113,14 @@ const ( PRIMARY KEY (user_id, token), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS user_phone ( + user_id TEXT NOT NULL, + phone_number TEXT NOT NULL, + verified INT NOT NULL, + PRIMARY KEY (user_id, phone_number), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX idx_user_phone_number ON user_phone (phone_number); CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, version INT NOT NULL @@ -261,6 +269,10 @@ const ( ) ` + selectPhoneNumbersQuery = `SELECT phone_number, verified FROM user_phone WHERE user_id = ?` + insertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number, verified) VALUES (?, ?, 0)` + updatePhoneNumberVerifiedQuery = `UPDATE user_phone SET verified=1 WHERE user_id = ? AND phone_number = ?` + insertTierQuery = ` INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -402,6 +414,14 @@ const ( ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0); ALTER TABLE user ADD COLUMN stats_sms INT NOT NULL DEFAULT (0); ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0); + CREATE TABLE IF NOT EXISTS user_phone ( + user_id TEXT NOT NULL, + phone_number TEXT NOT NULL, + verified INT NOT NULL, + PRIMARY KEY (user_id, phone_number), + FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX idx_user_phone_number ON user_phone (phone_number); ` ) @@ -631,6 +651,56 @@ func (a *Manager) RemoveExpiredTokens() error { return nil } +func (a *Manager) PhoneNumbers(userID string) ([]*PhoneNumber, error) { + rows, err := a.db.Query(selectPhoneNumbersQuery, userID) + if err != nil { + return nil, err + } + defer rows.Close() + phoneNumbers := make([]*PhoneNumber, 0) + for { + phoneNumber, err := a.readPhoneNumber(rows) + if err == ErrPhoneNumberNotFound { + break + } else if err != nil { + return nil, err + } + phoneNumbers = append(phoneNumbers, phoneNumber) + } + return phoneNumbers, nil +} + +func (a *Manager) readPhoneNumber(rows *sql.Rows) (*PhoneNumber, error) { + var phoneNumber string + var verified bool + if !rows.Next() { + return nil, ErrPhoneNumberNotFound + } + if err := rows.Scan(&phoneNumber, &verified); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + return &PhoneNumber{ + Number: phoneNumber, + Verified: verified, + }, nil +} + +func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error { + if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil { + return err + } + return nil +} + +func (a *Manager) MarkPhoneNumberVerified(userID string, phoneNumber string) error { + if _, err := a.db.Exec(updatePhoneNumberVerifiedQuery, userID, phoneNumber); err != nil { + return err + } + return nil +} + // RemoveDeletedUsers deletes all users that have been marked deleted for func (a *Manager) RemoveDeletedUsers() error { if _, err := a.db.Exec(deleteUsersMarkedQuery, time.Now().Unix()); err != nil { diff --git a/user/types.go b/user/types.go index 6340229..8f579e8 100644 --- a/user/types.go +++ b/user/types.go @@ -71,6 +71,11 @@ type TokenUpdate struct { LastOrigin netip.Addr } +type PhoneNumber struct { + Number string + Verified bool +} + // Prefs represents a user's configuration settings type Prefs struct { Language *string `json:"language,omitempty"` @@ -282,5 +287,6 @@ var ( ErrUserNotFound = errors.New("user not found") ErrTierNotFound = errors.New("tier not found") ErrTokenNotFound = errors.New("token not found") + ErrPhoneNumberNotFound = errors.New("phone number not found") ErrTooManyReservations = errors.New("new tier has lower reservation limit") ) From f99159ee5bed8189ad4ad83fdfab50bb9820319a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 12 May 2023 20:01:12 -0400 Subject: [PATCH 08/97] WIP calls, remove SMS --- cmd/serve.go | 5 +- cmd/tier.go | 8 -- docs/publish.md | 150 +++----------------------------- server/config.go | 2 - server/errors.go | 9 +- server/server.go | 42 ++++----- server/server_account.go | 14 ++- server/server_payments.go | 2 - server/server_twilio.go | 31 ++++--- server/types.go | 1 - server/visitor.go | 36 ++------ user/manager.go | 55 ++++++------ user/types.go | 4 +- web/public/config.js | 1 - web/public/static/langs/en.json | 11 +-- web/src/components/Account.js | 62 ++++++++----- 16 files changed, 132 insertions(+), 301 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 42b7233..9e02057 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -71,7 +71,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for SMS and calling, e.g. AC123..."}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for phone calls, e.g. AC123..."}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-from-number", Aliases: []string{"twilio_from_number"}, EnvVars: []string{"NTFY_TWILIO_FROM_NUMBER"}, Usage: "Twilio number to use for outgoing calls and text messages"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}), @@ -84,7 +84,6 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), - altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-sms-daily-limit", Aliases: []string{"visitor_sms_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_SMS_DAILY_LIMIT"}, Value: server.DefaultVisitorSMSDailyLimit, Usage: "max number of SMS messages per visitor per day"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-call-daily-limit", Aliases: []string{"visitor_call_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_CALL_DAILY_LIMIT"}, Value: server.DefaultVisitorCallDailyLimit, Usage: "max number of phone calls per visitor per day"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}), @@ -172,7 +171,6 @@ func execServe(c *cli.Context) error { visitorMessageDailyLimit := c.Int("visitor-message-daily-limit") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish") - visitorSMSDailyLimit := c.Int("visitor-sms-daily-limit") visitorCallDailyLimit := c.Int("visitor-call-daily-limit") behindProxy := c.Bool("behind-proxy") stripeSecretKey := c.String("stripe-secret-key") @@ -336,7 +334,6 @@ func execServe(c *cli.Context) error { conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish - conf.VisitorSMSDailyLimit = visitorSMSDailyLimit conf.VisitorCallDailyLimit = visitorCallDailyLimit conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting conf.BehindProxy = behindProxy diff --git a/cmd/tier.go b/cmd/tier.go index 6b95bdd..e77927c 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -18,7 +18,6 @@ const ( defaultMessageLimit = 5000 defaultMessageExpiryDuration = "12h" defaultEmailLimit = 20 - defaultSMSLimit = 10 defaultCallLimit = 10 defaultReservationLimit = 3 defaultAttachmentFileSizeLimit = "15M" @@ -50,7 +49,6 @@ var cmdTier = &cli.Command{ &cli.Int64Flag{Name: "message-limit", Value: defaultMessageLimit, Usage: "daily message limit"}, &cli.StringFlag{Name: "message-expiry-duration", Value: defaultMessageExpiryDuration, Usage: "duration after which messages are deleted"}, &cli.Int64Flag{Name: "email-limit", Value: defaultEmailLimit, Usage: "daily email limit"}, - &cli.Int64Flag{Name: "sms-limit", Value: defaultSMSLimit, Usage: "daily SMS limit"}, &cli.Int64Flag{Name: "call-limit", Value: defaultCallLimit, Usage: "daily phone call limit"}, &cli.Int64Flag{Name: "reservation-limit", Value: defaultReservationLimit, Usage: "topic reservation limit"}, &cli.StringFlag{Name: "attachment-file-size-limit", Value: defaultAttachmentFileSizeLimit, Usage: "per-attachment file size limit"}, @@ -95,7 +93,6 @@ Examples: &cli.Int64Flag{Name: "message-limit", Usage: "daily message limit"}, &cli.StringFlag{Name: "message-expiry-duration", Usage: "duration after which messages are deleted"}, &cli.Int64Flag{Name: "email-limit", Usage: "daily email limit"}, - &cli.Int64Flag{Name: "sms-limit", Usage: "daily SMS limit"}, &cli.Int64Flag{Name: "call-limit", Usage: "daily phone call limit"}, &cli.Int64Flag{Name: "reservation-limit", Usage: "topic reservation limit"}, &cli.StringFlag{Name: "attachment-file-size-limit", Usage: "per-attachment file size limit"}, @@ -221,7 +218,6 @@ func execTierAdd(c *cli.Context) error { MessageLimit: c.Int64("message-limit"), MessageExpiryDuration: messageExpiryDuration, EmailLimit: c.Int64("email-limit"), - SMSLimit: c.Int64("sms-limit"), CallLimit: c.Int64("call-limit"), ReservationLimit: c.Int64("reservation-limit"), AttachmentFileSizeLimit: attachmentFileSizeLimit, @@ -275,9 +271,6 @@ func execTierChange(c *cli.Context) error { if c.IsSet("email-limit") { tier.EmailLimit = c.Int64("email-limit") } - if c.IsSet("sms-limit") { - tier.SMSLimit = c.Int64("sms-limit") - } if c.IsSet("call-limit") { tier.CallLimit = c.Int64("call-limit") } @@ -371,7 +364,6 @@ func printTier(c *cli.Context, tier *user.Tier) { fmt.Fprintf(c.App.ErrWriter, "- Message limit: %d\n", tier.MessageLimit) fmt.Fprintf(c.App.ErrWriter, "- Message expiry duration: %s (%d seconds)\n", tier.MessageExpiryDuration.String(), int64(tier.MessageExpiryDuration.Seconds())) fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit) - fmt.Fprintf(c.App.ErrWriter, "- SMS limit: %d\n", tier.SMSLimit) fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit) fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit) fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit)) diff --git a/docs/publish.md b/docs/publish.md index 1d1109e..72398a7 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2695,51 +2695,48 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
Publishing a message via e-mail
-## Text message (SMS) +## Phone calls _Supported on:_ :material-android: :material-apple: :material-firefox: -You can forward messages as text message (SMS) by specifying a phone number a header. Similar to email notifications, -this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have the ntfy app -installed on their phone. +You can use ntfy to call a phone and **read the message out loud using text-to-speech**, by specifying a phone number a header. +Similar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have +the ntfy app installed on their phone. -To forward a message as an SMS, pass a phone number in the `X-SMS` header (or its alias: `SMS`), prefixed with a plus sign -and the country code, e.g. `+12223334444`. +Phone numbers have to be previously verified (via the web app). To forward a message as a phone call, pass a phone number +in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`. You may +also simply pass `yes` as a value if you only have one verified phone number. On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans. === "Command line (curl)" ``` curl \ - -H "SMS: +12223334444" \ - -d "Your garage seems to be on fire 🔥. You should probably check that out, and call 0118 999 881 999 119 725 3." \ + -H "Call: +12223334444" \ + -d "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help." \ ntfy.sh/alerts ``` === "ntfy CLI" ``` ntfy publish \ - --email=phil@example.com \ - --tags=warning,skull,backup-host,ssh-login \ - --priority=high \ - alerts "Unknown login from 5.31.23.83 to backups.example.com" + --call=+12223334444 \ + alerts "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help." ``` === "HTTP" ``` http POST /alerts HTTP/1.1 Host: ntfy.sh - Email: phil@example.com - Tags: warning,skull,backup-host,ssh-login - Priority: high + Call: +12223334444 - Unknown login from 5.31.23.83 to backups.example.com + Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help. ``` === "JavaScript" ``` javascript fetch('https://ntfy.sh/alerts', { method: 'POST', - body: "Unknown login from 5.31.23.83 to backups.example.com", + body: "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.", headers: { 'Email': 'phil@example.com', 'Tags': 'warning,skull,backup-host,ssh-login', @@ -2807,125 +2804,6 @@ Here's what that looks like in Google Mail:
E-mail notification
- -## Phone calls -_Supported on:_ :material-android: :material-apple: :material-firefox: - -You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that -you'd like to persist longer, or to blast-notify yourself on all possible channels. - -Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`). -Only one e-mail address is supported. - -Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the -default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of -that, your IP address appears in the e-mail body. This is to prevent abuse. - -=== "Command line (curl)" - ``` - curl \ - -H "Email: phil@example.com" \ - -H "Tags: warning,skull,backup-host,ssh-login" \ - -H "Priority: high" \ - -d "Unknown login from 5.31.23.83 to backups.example.com" \ - ntfy.sh/alerts - curl -H "Email: phil@example.com" -d "You've Got Mail" - curl -d "You've Got Mail" "ntfy.sh/alerts?email=phil@example.com" - ``` - -=== "ntfy CLI" - ``` - ntfy publish \ - --email=phil@example.com \ - --tags=warning,skull,backup-host,ssh-login \ - --priority=high \ - alerts "Unknown login from 5.31.23.83 to backups.example.com" - ``` - -=== "HTTP" - ``` http - POST /alerts HTTP/1.1 - Host: ntfy.sh - Email: phil@example.com - Tags: warning,skull,backup-host,ssh-login - Priority: high - - Unknown login from 5.31.23.83 to backups.example.com - ``` - -=== "JavaScript" - ``` javascript - fetch('https://ntfy.sh/alerts', { - method: 'POST', - body: "Unknown login from 5.31.23.83 to backups.example.com", - headers: { - 'Email': 'phil@example.com', - 'Tags': 'warning,skull,backup-host,ssh-login', - 'Priority': 'high' - } - }) - ``` - -=== "Go" - ``` go - req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts", - strings.NewReader("Unknown login from 5.31.23.83 to backups.example.com")) - req.Header.Set("Email", "phil@example.com") - req.Header.Set("Tags", "warning,skull,backup-host,ssh-login") - req.Header.Set("Priority", "high") - http.DefaultClient.Do(req) - ``` - -=== "PowerShell" - ``` powershell - $Request = @{ - Method = "POST" - URI = "https://ntfy.sh/alerts" - Headers = @{ - Title = "Low disk space alert" - Priority = "high" - Tags = "warning,skull,backup-host,ssh-login") - Email = "phil@example.com" - } - Body = "Unknown login from 5.31.23.83 to backups.example.com" - } - Invoke-RestMethod @Request - ``` - -=== "Python" - ``` python - requests.post("https://ntfy.sh/alerts", - data="Unknown login from 5.31.23.83 to backups.example.com", - headers={ - "Email": "phil@example.com", - "Tags": "warning,skull,backup-host,ssh-login", - "Priority": "high" - }) - ``` - -=== "PHP" - ``` php-inline - file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => - "Content-Type: text/plain\r\n" . - "Email: phil@example.com\r\n" . - "Tags: warning,skull,backup-host,ssh-login\r\n" . - "Priority: high", - 'content' => 'Unknown login from 5.31.23.83 to backups.example.com' - ] - ])); - ``` - -Here's what that looks like in Google Mail: - -
- ![e-mail notification](static/img/screenshot-email.png){ width=600 } -
E-mail notification
-
- - ## Authentication Depending on whether the server is configured to support [access control](config.md#access-control), some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. diff --git a/server/config.go b/server/config.go index 352d91f..3fd88e8 100644 --- a/server/config.go +++ b/server/config.go @@ -47,7 +47,6 @@ const ( DefaultVisitorMessageDailyLimit = 0 DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitReplenish = time.Hour - DefaultVisitorSMSDailyLimit = 10 DefaultVisitorCallDailyLimit = 10 DefaultVisitorAccountCreationLimitBurst = 3 DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour @@ -130,7 +129,6 @@ type Config struct { VisitorMessageDailyLimit int VisitorEmailLimitBurst int VisitorEmailLimitReplenish time.Duration - VisitorSMSDailyLimit int VisitorCallDailyLimit int VisitorAccountCreationLimitBurst int VisitorAccountCreationLimitReplenish time.Duration diff --git a/server/errors.go b/server/errors.go index d02fb07..7c4e689 100644 --- a/server/errors.go +++ b/server/errors.go @@ -106,14 +106,16 @@ var ( errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", "", nil} errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", "", nil} errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil} - errHTTPBadRequestTwilioDisabled = &errHTTP{40030, http.StatusBadRequest, "invalid request: SMS and calling is disabled", "https://ntfy.sh/docs/publish/#sms", nil} - errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40031, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#sms", nil} + errHTTPBadRequestTwilioDisabled = &errHTTP{40030, http.StatusBadRequest, "invalid request: Calling is disabled", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40031, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", "", nil} errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", "", nil} errHTTPConflictSubscriptionExists = &errHTTP{40903, http.StatusConflict, "conflict: topic subscription already exists", "", nil} + errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil} + errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil} errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil} errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", "", nil} @@ -126,8 +128,7 @@ var ( errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil} errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit - errHTTPTooManyRequestsLimitSMS = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily SMS quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} - errHTTPTooManyRequestsLimitCalls = &errHTTP{42911, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} + errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil} errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil} errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil} diff --git a/server/server.go b/server/server.go index 056e0a6..b474da7 100644 --- a/server/server.go +++ b/server/server.go @@ -534,7 +534,6 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi EnableLogin: s.config.EnableLogin, EnableSignup: s.config.EnableSignup, EnablePayments: s.config.StripeSecretKey != "", - EnableSMS: s.config.TwilioAccount != "", EnableCalls: s.config.TwilioAccount != "", EnableReservations: s.config.EnableReservations, BillingContact: s.config.BillingContact, @@ -676,7 +675,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e return nil, err } m := newDefaultMessage(t.ID, "") - cache, firebase, email, sms, call, unifiedpush, e := s.parsePublishParams(r, m) + cache, firebase, email, call, unifiedpush, e := s.parsePublishParams(r, m) if e != nil { return nil, e.With(t) } @@ -690,8 +689,6 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e return nil, errHTTPTooManyRequestsLimitMessages.With(t) } else if email != "" && !vrate.EmailAllowed() { return nil, errHTTPTooManyRequestsLimitEmails.With(t) - } else if sms != "" && !vrate.SMSAllowed() { - return nil, errHTTPTooManyRequestsLimitSMS.With(t) } else if call != "" && !vrate.CallAllowed() { return nil, errHTTPTooManyRequestsLimitCalls.With(t) } @@ -734,9 +731,6 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e if s.smtpSender != nil && email != "" { go s.sendEmail(v, m, email) } - if s.config.TwilioAccount != "" && sms != "" { - go s.sendSMS(v, r, m, sms) - } if s.config.TwilioAccount != "" && call != "" { go s.callPhone(v, r, m, call) } @@ -849,7 +843,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { } } -func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, sms, call string, unifiedpush bool, err *errHTTP) { +func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) { cache = readBoolParam(r, true, "x-cache", "cache") firebase = readBoolParam(r, true, "x-firebase", "firebase") m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t")) @@ -865,7 +859,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if attach != "" { if !urlRegex.MatchString(attach) { - return false, false, "", "", "", false, errHTTPBadRequestAttachmentURLInvalid + return false, false, "", "", false, errHTTPBadRequestAttachmentURLInvalid } m.Attachment.URL = attach if m.Attachment.Name == "" { @@ -883,25 +877,19 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } if icon != "" { if !urlRegex.MatchString(icon) { - return false, false, "", "", "", false, errHTTPBadRequestIconURLInvalid + return false, false, "", "", false, errHTTPBadRequestIconURLInvalid } m.Icon = icon } email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") if s.smtpSender == nil && email != "" { - return false, false, "", "", "", false, errHTTPBadRequestEmailDisabled - } - sms = readParam(r, "x-sms", "sms") - if sms != "" && s.config.TwilioAccount == "" { - return false, false, "", "", "", false, errHTTPBadRequestTwilioDisabled - } else if sms != "" && !phoneNumberRegex.MatchString(sms) { - return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid + return false, false, "", "", false, errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") if call != "" && s.config.TwilioAccount == "" { - return false, false, "", "", "", false, errHTTPBadRequestTwilioDisabled + return false, false, "", "", false, errHTTPBadRequestTwilioDisabled } else if call != "" && !phoneNumberRegex.MatchString(call) { - return false, false, "", "", "", false, errHTTPBadRequestPhoneNumberInvalid + return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid } messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") if messageStr != "" { @@ -910,7 +898,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi var e error m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) if e != nil { - return false, false, "", "", "", false, errHTTPBadRequestPriorityInvalid + return false, false, "", "", false, errHTTPBadRequestPriorityInvalid } m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") for i, t := range m.Tags { @@ -919,18 +907,18 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") if delayStr != "" { if !cache { - return false, false, "", "", "", false, errHTTPBadRequestDelayNoCache + return false, false, "", "", false, errHTTPBadRequestDelayNoCache } if email != "" { - return false, false, "", "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) + return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) } delay, err := util.ParseFutureTime(delayStr, time.Now()) if err != nil { - return false, false, "", "", "", false, errHTTPBadRequestDelayCannotParse + return false, false, "", "", false, errHTTPBadRequestDelayCannotParse } else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() { - return false, false, "", "", "", false, errHTTPBadRequestDelayTooSmall + return false, false, "", "", false, errHTTPBadRequestDelayTooSmall } else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() { - return false, false, "", "", "", false, errHTTPBadRequestDelayTooLarge + return false, false, "", "", false, errHTTPBadRequestDelayTooLarge } m.Time = delay.Unix() } @@ -938,7 +926,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if actionsStr != "" { m.Actions, e = parseActions(actionsStr) if e != nil { - return false, false, "", "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) + return false, false, "", "", false, errHTTPBadRequestActionsInvalid.Wrap(e.Error()) } } unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too! @@ -952,7 +940,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi cache = false email = "" } - return cache, firebase, email, sms, call, unifiedpush, nil + return cache, firebase, email, call, unifiedpush, nil } // handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. diff --git a/server/server_account.go b/server/server_account.go index cb3a52e..a323bfe 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -56,7 +56,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis Messages: limits.MessageLimit, MessagesExpiryDuration: int64(limits.MessageExpiryDuration.Seconds()), Emails: limits.EmailLimit, - SMS: limits.SMSLimit, Calls: limits.CallLimit, Reservations: limits.ReservationsLimit, AttachmentTotalSize: limits.AttachmentTotalSizeLimit, @@ -69,8 +68,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis MessagesRemaining: stats.MessagesRemaining, Emails: stats.Emails, EmailsRemaining: stats.EmailsRemaining, - SMS: stats.SMS, - SMSRemaining: stats.SMSRemaining, Calls: stats.Calls, CallsRemaining: stats.CallsRemaining, Reservations: stats.Reservations, @@ -542,7 +539,7 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ // Check user is allowed to add phone numbers if u == nil || (u.IsUser() && u.Tier == nil) { return errHTTPUnauthorized - } else if u.IsUser() && u.Tier.SMSLimit == 0 && u.Tier.CallLimit == 0 { + } else if u.IsUser() && u.Tier.CallLimit == 0 { return errHTTPUnauthorized } // Actually add the unverified number, and send verification @@ -553,6 +550,9 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ }). Debug("Adding phone number, and sending verification") if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil { + if err == user.ErrPhoneNumberExists { + return errHTTPConflictPhoneNumberExists + } return err } if err := s.verifyPhone(v, r, req.Number); err != nil { @@ -570,10 +570,6 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R if !phoneNumberRegex.MatchString(req.Number) { return errHTTPBadRequestPhoneNumberInvalid } - // Check user is allowed to add phone numbers - if u == nil { - return errHTTPUnauthorized - } // Get phone numbers, and check if it's in the list phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) if err != nil { @@ -581,7 +577,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R } found := false for _, phoneNumber := range phoneNumbers { - if phoneNumber.Number == req.Number && phoneNumber.Verified { + if phoneNumber.Number == req.Number && !phoneNumber.Verified { found = true break } diff --git a/server/server_payments.go b/server/server_payments.go index bd91338..1e98d05 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -68,7 +68,6 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ Messages: freeTier.MessageLimit, MessagesExpiryDuration: int64(freeTier.MessageExpiryDuration.Seconds()), Emails: freeTier.EmailLimit, - SMS: freeTier.SMSLimit, Calls: freeTier.CallLimit, Reservations: freeTier.ReservationsLimit, AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit, @@ -98,7 +97,6 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ Messages: tier.MessageLimit, MessagesExpiryDuration: int64(tier.MessageExpiryDuration.Seconds()), Emails: tier.EmailLimit, - SMS: tier.SMSLimit, Calls: tier.CallLimit, Reservations: tier.ReservationLimit, AttachmentTotalSize: tier.AttachmentTotalSizeLimit, diff --git a/server/server_twilio.go b/server/server_twilio.go index 2f58773..a6b9109 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -15,7 +15,6 @@ import ( ) const ( - twilioMessageEndpoint = "Messages.json" twilioMessageFooterFormat = "This message was sent by %s via %s" twilioCallEndpoint = "Calls.json" twilioCallFormat = ` @@ -32,15 +31,6 @@ const (
` ) -func (s *Server) sendSMS(v *visitor, r *http.Request, m *message, to string) { - body := fmt.Sprintf("%s\n\n--\n%s", m.Message, s.messageFooter(v.User(), m)) - data := url.Values{} - data.Set("From", s.config.TwilioFromNumber) - data.Set("To", to) - data.Set("Body", body) - s.twilioMessagingRequest(v, r, m, metricSMSSentSuccess, metricSMSSentFailure, twilioMessageEndpoint, to, body, data) -} - func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(v.User(), m))) data := url.Values{} @@ -85,25 +75,38 @@ func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code data := url.Values{} data.Set("To", phoneNumber) data.Set("Code", code) - requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioAccount) + requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService) req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) if err != nil { return err } req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + log.Fields(httpContext(req)).Field("http_body", data.Encode()).Info("Twilio call") + ev := logvr(v, r). + Tag(tagTwilio). + Field("twilio_to", phoneNumber) resp, err := http.DefaultClient.Do(req) if err != nil { return err } else if resp.StatusCode != http.StatusOK { - return + if ev.IsTrace() { + response, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + ev.Field("twilio_response", string(response)) + } + ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode) + if resp.StatusCode == http.StatusNotFound { + return errHTTPGonePhoneVerificationExpired + } + return errHTTPInternalError } response, err := io.ReadAll(resp.Body) if err != nil { return err } - - ev := logvr(v, r).Tag(tagTwilio) if ev.IsTrace() { ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response") } else if ev.IsDebug() { diff --git a/server/types.go b/server/types.go index 1762294..9015a00 100644 --- a/server/types.go +++ b/server/types.go @@ -362,7 +362,6 @@ type apiConfigResponse struct { EnableLogin bool `json:"enable_login"` EnableSignup bool `json:"enable_signup"` EnablePayments bool `json:"enable_payments"` - EnableSMS bool `json:"enable_sms"` EnableCalls bool `json:"enable_calls"` EnableReservations bool `json:"enable_reservations"` BillingContact string `json:"billing_contact"` diff --git a/server/visitor.go b/server/visitor.go index 4de51e6..4895c3f 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -56,7 +56,6 @@ type visitor struct { requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages) messagesLimiter *util.FixedLimiter // Rate limiter for messages emailsLimiter *util.RateLimiter // Rate limiter for emails - smsLimiter *util.FixedLimiter // Rate limiter for SMS callsLimiter *util.FixedLimiter // Rate limiter for calls subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads @@ -81,7 +80,6 @@ type visitorLimits struct { EmailLimit int64 EmailLimitBurst int EmailLimitReplenish rate.Limit - SMSLimit int64 CallLimit int64 ReservationsLimit int64 AttachmentTotalSizeLimit int64 @@ -95,8 +93,6 @@ type visitorStats struct { MessagesRemaining int64 Emails int64 EmailsRemaining int64 - SMS int64 - SMSRemaining int64 Calls int64 CallsRemaining int64 Reservations int64 @@ -115,11 +111,10 @@ const ( ) func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { - var messages, emails, sms, calls int64 + var messages, emails, calls int64 if user != nil { messages = user.Stats.Messages emails = user.Stats.Emails - sms = user.Stats.SMS calls = user.Stats.Calls } v := &visitor{ @@ -134,13 +129,12 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana requestLimiter: nil, // Set in resetLimiters messagesLimiter: nil, // Set in resetLimiters, may be nil emailsLimiter: nil, // Set in resetLimiters - smsLimiter: nil, // Set in resetLimiters, may be nil callsLimiter: nil, // Set in resetLimiters, may be nil bandwidthLimiter: nil, // Set in resetLimiters accountLimiter: nil, // Set in resetLimiters, may be nil authLimiter: nil, // Set in resetLimiters, may be nil } - v.resetLimitersNoLock(messages, emails, sms, calls, false) + v.resetLimitersNoLock(messages, emails, calls, false) return v } @@ -168,9 +162,6 @@ func (v *visitor) contextNoLock() log.Context { fields["visitor_emails_remaining"] = info.Stats.EmailsRemaining } if v.config.TwilioAccount != "" { - fields["visitor_sms"] = info.Stats.SMS - fields["visitor_sms_limit"] = info.Limits.SMSLimit - fields["visitor_sms_remaining"] = info.Stats.SMSRemaining fields["visitor_calls"] = info.Stats.Calls fields["visitor_calls_limit"] = info.Limits.CallLimit fields["visitor_calls_remaining"] = info.Stats.CallsRemaining @@ -238,12 +229,6 @@ func (v *visitor) EmailAllowed() bool { return v.emailsLimiter.Allow() } -func (v *visitor) SMSAllowed() bool { - v.mu.RLock() // limiters could be replaced! - defer v.mu.RUnlock() - return v.smsLimiter.Allow() -} - func (v *visitor) CallAllowed() bool { v.mu.RLock() // limiters could be replaced! defer v.mu.RUnlock() @@ -330,7 +315,6 @@ func (v *visitor) Stats() *user.Stats { return &user.Stats{ Messages: v.messagesLimiter.Value(), Emails: v.emailsLimiter.Value(), - SMS: v.smsLimiter.Value(), Calls: v.callsLimiter.Value(), } } @@ -340,7 +324,6 @@ func (v *visitor) ResetStats() { defer v.mu.RUnlock() v.emailsLimiter.Reset() v.messagesLimiter.Reset() - v.smsLimiter.Reset() v.callsLimiter.Reset() } @@ -372,11 +355,11 @@ func (v *visitor) SetUser(u *user.User) { shouldResetLimiters := v.user.TierID() != u.TierID() // TierID works with nil receiver v.user = u // u may be nil! if shouldResetLimiters { - var messages, emails, sms, calls int64 + var messages, emails, calls int64 if u != nil { - messages, emails, sms, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.SMS, u.Stats.Calls + messages, emails, calls = u.Stats.Messages, u.Stats.Emails, u.Stats.Calls } - v.resetLimitersNoLock(messages, emails, sms, calls, true) + v.resetLimitersNoLock(messages, emails, calls, true) } } @@ -391,12 +374,11 @@ func (v *visitor) MaybeUserID() string { return "" } -func (v *visitor) resetLimitersNoLock(messages, emails, sms, calls int64, enqueueUpdate bool) { +func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpdate bool) { limits := v.limitsNoLock() v.requestLimiter = rate.NewLimiter(limits.RequestLimitReplenish, limits.RequestLimitBurst) v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages) v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails) - v.smsLimiter = util.NewFixedLimiterWithValue(limits.SMSLimit, sms) v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls) v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay) if v.user == nil { @@ -410,7 +392,6 @@ func (v *visitor) resetLimitersNoLock(messages, emails, sms, calls int64, enqueu go v.userManager.EnqueueUserStats(v.user.ID, &user.Stats{ Messages: messages, Emails: emails, - SMS: sms, Calls: calls, }) } @@ -440,7 +421,6 @@ func tierBasedVisitorLimits(conf *Config, tier *user.Tier) *visitorLimits { EmailLimit: tier.EmailLimit, EmailLimitBurst: util.MinMax(int(float64(tier.EmailLimit)*visitorEmailLimitBurstRate), conf.VisitorEmailLimitBurst, visitorEmailLimitBurstMax), EmailLimitReplenish: dailyLimitToRate(tier.EmailLimit), - SMSLimit: tier.SMSLimit, CallLimit: tier.CallLimit, ReservationsLimit: tier.ReservationLimit, AttachmentTotalSizeLimit: tier.AttachmentTotalSizeLimit, @@ -464,7 +444,6 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits { EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation! EmailLimitBurst: conf.VisitorEmailLimitBurst, EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish), - SMSLimit: int64(conf.VisitorSMSDailyLimit), CallLimit: int64(conf.VisitorCallDailyLimit), ReservationsLimit: visitorDefaultReservationsLimit, AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit, @@ -511,7 +490,6 @@ func (v *visitor) Info() (*visitorInfo, error) { func (v *visitor) infoLightNoLock() *visitorInfo { messages := v.messagesLimiter.Value() emails := v.emailsLimiter.Value() - sms := v.smsLimiter.Value() calls := v.callsLimiter.Value() limits := v.limitsNoLock() stats := &visitorStats{ @@ -519,8 +497,6 @@ func (v *visitor) infoLightNoLock() *visitorInfo { MessagesRemaining: zeroIfNegative(limits.MessageLimit - messages), Emails: emails, EmailsRemaining: zeroIfNegative(limits.EmailLimit - emails), - SMS: sms, - SMSRemaining: zeroIfNegative(limits.SMSLimit - sms), Calls: calls, CallsRemaining: zeroIfNegative(limits.CallLimit - calls), } diff --git a/user/manager.go b/user/manager.go index 824622b..7c179cf 100644 --- a/user/manager.go +++ b/user/manager.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3" // SQLite driver "github.com/stripe/stripe-go/v74" "golang.org/x/crypto/bcrypt" @@ -55,7 +56,6 @@ const ( messages_limit INT NOT NULL, messages_expiry_duration INT NOT NULL, emails_limit INT NOT NULL, - sms_limit INT NOT NULL, calls_limit INT NOT NULL, reservations_limit INT NOT NULL, attachment_file_size_limit INT NOT NULL, @@ -78,7 +78,6 @@ const ( sync_topic TEXT NOT NULL, stats_messages INT NOT NULL DEFAULT (0), stats_emails INT NOT NULL DEFAULT (0), - stats_sms INT NOT NULL DEFAULT (0), stats_calls INT NOT NULL DEFAULT (0), stripe_customer_id TEXT, stripe_subscription_id TEXT, @@ -135,26 +134,26 @@ const ( ` selectUserByIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.id = ? ` selectUserByNameQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE user = ? ` selectUserByTokenQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u JOIN user_token tk on u.id = tk.user_id LEFT JOIN tier t on t.id = u.tier_id WHERE tk.token = ? AND (tk.expires = 0 OR tk.expires >= ?) ` selectUserByStripeCustomerIDQuery = ` - SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_sms, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.sms_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id + SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.stripe_customer_id = ? @@ -185,8 +184,8 @@ const ( updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?` updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?` updateUserPrefsQuery = `UPDATE user SET prefs = ? WHERE id = ?` - updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_sms = ?, stats_calls = ? WHERE id = ?` - updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_sms = 0, stats_calls = 0` + updateUserStatsQuery = `UPDATE user SET stats_messages = ?, stats_emails = ?, stats_calls = ? WHERE id = ?` + updateUserStatsResetAllQuery = `UPDATE user SET stats_messages = 0, stats_emails = 0, stats_calls = 0` updateUserDeletedQuery = `UPDATE user SET deleted = ? WHERE id = ?` deleteUsersMarkedQuery = `DELETE FROM user WHERE deleted < ?` deleteUserQuery = `DELETE FROM user WHERE user = ?` @@ -274,25 +273,25 @@ const ( updatePhoneNumberVerifiedQuery = `UPDATE user_phone SET verified=1 WHERE user_id = ? AND phone_number = ?` insertTierQuery = ` - INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` updateTierQuery = ` UPDATE tier - SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, sms_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ? + SET name = ?, messages_limit = ?, messages_expiry_duration = ?, emails_limit = ?, calls_limit = ?, reservations_limit = ?, attachment_file_size_limit = ?, attachment_total_size_limit = ?, attachment_expiry_duration = ?, attachment_bandwidth_limit = ?, stripe_monthly_price_id = ?, stripe_yearly_price_id = ? WHERE code = ? ` selectTiersQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier ` selectTierByCodeQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier WHERE code = ? ` selectTierByPriceIDQuery = ` - SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, sms_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id + SELECT id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id FROM tier WHERE (stripe_monthly_price_id = ? OR stripe_yearly_price_id = ?) ` @@ -410,9 +409,7 @@ const ( // 3 -> 4 migrate3To4UpdateQueries = ` - ALTER TABLE tier ADD COLUMN sms_limit INT NOT NULL DEFAULT (0); ALTER TABLE tier ADD COLUMN calls_limit INT NOT NULL DEFAULT (0); - ALTER TABLE user ADD COLUMN stats_sms INT NOT NULL DEFAULT (0); ALTER TABLE user ADD COLUMN stats_calls INT NOT NULL DEFAULT (0); CREATE TABLE IF NOT EXISTS user_phone ( user_id TEXT NOT NULL, @@ -689,6 +686,9 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (*PhoneNumber, error) { func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error { if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil { + if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { + return ErrPhoneNumberExists + } return err } return nil @@ -783,11 +783,10 @@ func (a *Manager) writeUserStatsQueue() error { "user_id": userID, "messages_count": update.Messages, "emails_count": update.Emails, - "sms_count": update.SMS, "calls_count": update.Calls, }). Trace("Updating stats for user %s", userID) - if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.SMS, update.Calls, userID); err != nil { + if _, err := tx.Exec(updateUserStatsQuery, update.Messages, update.Emails, update.Calls, userID); err != nil { return err } } @@ -869,6 +868,9 @@ func (a *Manager) AddUser(username, password string, role Role) error { userID := util.RandomStringPrefix(userIDPrefix, userIDLength) syncTopic, now := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength), time.Now().Unix() if _, err = a.db.Exec(insertUserQuery, userID, username, hash, role, syncTopic, now); err != nil { + if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { + return ErrUserExists + } return err } return nil @@ -996,12 +998,12 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() var id, username, hash, role, prefs, syncTopic string var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripeSubscriptionInterval, stripeMonthlyPriceID, stripeYearlyPriceID, tierID, tierCode, tierName sql.NullString - var messages, emails, sms, calls int64 - var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 + var messages, emails, calls int64 + var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt, deleted sql.NullInt64 if !rows.Next() { return nil, ErrUserNotFound } - if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &sms, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &smsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + if err := rows.Scan(&id, &username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &calls, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionInterval, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &deleted, &tierID, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -1016,7 +1018,6 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { Stats: &Stats{ Messages: messages, Emails: emails, - SMS: sms, Calls: calls, }, Billing: &Billing{ @@ -1041,7 +1042,6 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { MessageLimit: messagesLimit.Int64, MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, EmailLimit: emailsLimit.Int64, - SMSLimit: smsLimit.Int64, CallLimit: callsLimit.Int64, ReservationLimit: reservationsLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, @@ -1348,7 +1348,7 @@ func (a *Manager) AddTier(tier *Tier) error { if tier.ID == "" { tier.ID = util.RandomStringPrefix(tierIDPrefix, tierIDLength) } - if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.SMSLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil { + if _, err := a.db.Exec(insertTierQuery, tier.ID, tier.Code, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID)); err != nil { return err } return nil @@ -1356,7 +1356,7 @@ func (a *Manager) AddTier(tier *Tier) error { // UpdateTier updates a tier's properties in the database func (a *Manager) UpdateTier(tier *Tier) error { - if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.SMSLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil { + if _, err := a.db.Exec(updateTierQuery, tier.Name, tier.MessageLimit, int64(tier.MessageExpiryDuration.Seconds()), tier.EmailLimit, tier.CallLimit, tier.ReservationLimit, tier.AttachmentFileSizeLimit, tier.AttachmentTotalSizeLimit, int64(tier.AttachmentExpiryDuration.Seconds()), tier.AttachmentBandwidthLimit, nullString(tier.StripeMonthlyPriceID), nullString(tier.StripeYearlyPriceID), tier.Code); err != nil { return err } return nil @@ -1425,11 +1425,11 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) { func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { var id, code, name string var stripeMonthlyPriceID, stripeYearlyPriceID sql.NullString - var messagesLimit, messagesExpiryDuration, emailsLimit, smsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 + var messagesLimit, messagesExpiryDuration, emailsLimit, callsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, attachmentBandwidthLimit sql.NullInt64 if !rows.Next() { return nil, ErrTierNotFound } - if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &smsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { + if err := rows.Scan(&id, &code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &callsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &attachmentBandwidthLimit, &stripeMonthlyPriceID, &stripeYearlyPriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -1442,7 +1442,6 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { MessageLimit: messagesLimit.Int64, MessageExpiryDuration: time.Duration(messagesExpiryDuration.Int64) * time.Second, EmailLimit: emailsLimit.Int64, - SMSLimit: smsLimit.Int64, CallLimit: callsLimit.Int64, ReservationLimit: reservationsLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, diff --git a/user/types.go b/user/types.go index 8f579e8..51a2b3f 100644 --- a/user/types.go +++ b/user/types.go @@ -91,7 +91,6 @@ type Tier struct { MessageLimit int64 // Daily message limit MessageExpiryDuration time.Duration // Cache duration for messages EmailLimit int64 // Daily email limit - SMSLimit int64 // Daily SMS limit CallLimit int64 // Daily phone call limit ReservationLimit int64 // Number of topic reservations allowed by user AttachmentFileSizeLimit int64 // Max file size per file (bytes) @@ -138,7 +137,6 @@ type NotificationPrefs struct { type Stats struct { Messages int64 Emails int64 - SMS int64 Calls int64 } @@ -285,8 +283,10 @@ var ( ErrUnauthorized = errors.New("unauthorized") ErrInvalidArgument = errors.New("invalid argument") ErrUserNotFound = errors.New("user not found") + ErrUserExists = errors.New("user already exists") ErrTierNotFound = errors.New("tier not found") ErrTokenNotFound = errors.New("token not found") ErrPhoneNumberNotFound = errors.New("phone number not found") ErrTooManyReservations = errors.New("new tier has lower reservation limit") + ErrPhoneNumberExists = errors.New("phone number already exists") ) diff --git a/web/public/config.js b/web/public/config.js index f5a5759..b49e440 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -12,7 +12,6 @@ var config = { enable_signup: true, enable_payments: true, enable_reservations: true, - enable_sms: true, enable_calls: true, billing_contact: "", disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"] diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 600994b..86330f1 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -127,9 +127,6 @@ "publish_dialog_email_label": "Email", "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "publish_dialog_email_reset": "Remove email forward", - "publish_dialog_sms_label": "SMS", - "publish_dialog_sms_placeholder": "Phone number to send SMS to, e.g. +12223334444", - "publish_dialog_sms_reset": "Remove SMS message", "publish_dialog_call_label": "Phone call", "publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444", "publish_dialog_call_reset": "Remove phone call", @@ -144,7 +141,6 @@ "publish_dialog_other_features": "Other features:", "publish_dialog_chip_click_label": "Click URL", "publish_dialog_chip_email_label": "Forward to email", - "publish_dialog_chip_sms_label": "Send SMS", "publish_dialog_chip_call_label": "Phone call", "publish_dialog_chip_attach_url_label": "Attach file by URL", "publish_dialog_chip_attach_file_label": "Attach local file", @@ -190,6 +186,8 @@ "account_basics_password_dialog_confirm_password_label": "Confirm password", "account_basics_password_dialog_button_submit": "Change password", "account_basics_password_dialog_current_password_incorrect": "Password incorrect", + "account_basics_phone_numbers_title": "Phone numbers", + "account_basics_phone_numbers_description": "For phone call notifications", "account_usage_title": "Usage", "account_usage_of_limit": "of {{limit}}", "account_usage_unlimited": "Unlimited", @@ -211,8 +209,6 @@ "account_basics_tier_manage_billing_button": "Manage billing", "account_usage_messages_title": "Published messages", "account_usage_emails_title": "Emails sent", - "account_usage_sms_title": "SMS sent", - "account_usage_sms_none": "No SMS can be sent with this account", "account_usage_calls_title": "Phone calls made", "account_usage_calls_none": "No phone calls can be made with this account", "account_usage_reservations_title": "Reserved topics", @@ -244,9 +240,6 @@ "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daily messages", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} daily email", "account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails", - "account_upgrade_dialog_tier_features_sms_one": "{{sms}} daily SMS", - "account_upgrade_dialog_tier_features_sms_other": "{{sms}} daily SMS", - "account_upgrade_dialog_tier_features_no_sms": "No daily SMS", "account_upgrade_dialog_tier_features_calls_one": "{{calls}} daily phone calls", "account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls", "account_upgrade_dialog_tier_features_no_calls": "No daily phone calls", diff --git a/web/src/components/Account.js b/web/src/components/Account.js index dc80bab..b5294cd 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -3,7 +3,7 @@ import {useContext, useState} from 'react'; import { Alert, CardActions, - CardContent, + CardContent, Chip, FormControl, LinearProgress, Link, @@ -52,6 +52,7 @@ import MenuItem from "@mui/material/MenuItem"; import DialogContentText from "@mui/material/DialogContentText"; import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; import {ProChip} from "./SubscriptionPopup"; +import AddIcon from "@mui/icons-material/Add"; const Account = () => { if (!session.exists()) { @@ -80,6 +81,7 @@ const Basics = () => { + @@ -320,6 +322,40 @@ const AccountType = () => { ) }; +const PhoneNumbers = () => { + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const labelId = "prefPhoneNumbers"; + + const handleAdd = () => { + + }; + + const handleClick = () => { + + }; + + const handleDelete = () => { + + }; + + return ( + +
+ {account?.phone_numbers.map(p => + navigator.clipboard.writeText(p.number)} + onDelete={() => handleDelete(p.number)} + /> + )} + handleAdd()}> +
+
+ ) +}; + const Stats = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); @@ -380,23 +416,6 @@ const Stats = () => { value={account.role === Role.USER ? normalize(account.stats.emails, account.limits.emails) : 100} /> - {(account.role === Role.ADMIN || account.limits.sms > 0) && - - {t("account_usage_sms_title")} - - - }> -
- {account.stats.sms.toLocaleString()} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.sms.toLocaleString() }) : t("account_usage_unlimited")} -
- 0 ? normalize(account.stats.sms, account.limits.sms) : 100} - /> -
- } {(account.role === Role.ADMIN || account.limits.calls > 0) && @@ -410,7 +429,7 @@ const Stats = () => { 0 ? normalize(account.stats.calls, account.limits.calls) : 100} + value={account.role === Role.USER && account.limits.calls > 0 ? normalize(account.stats.calls, account.limits.calls) : 100} /> } @@ -439,11 +458,6 @@ const Stats = () => { {t("account_usage_reservations_none")} } - {config.enable_sms && account.role === Role.USER && account.limits.sms === 0 && - {t("account_usage_sms_title")}{config.enable_payments && }}> - {t("account_usage_sms_none")} - - } {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && {t("account_usage_calls_title")}{config.enable_payments && }}> {t("account_usage_calls_none")} From cea434a57cccadbad697322767fc8ef52818d343 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 12 May 2023 21:47:41 -0400 Subject: [PATCH 09/97] WIP Twilio --- server/server.go | 5 + server/server_account.go | 66 ++++------ server/types.go | 31 ++--- user/manager.go | 39 +++--- user/types.go | 5 - web/public/static/langs/ar.json | 4 +- web/public/static/langs/bg.json | 2 +- web/public/static/langs/cs.json | 4 +- web/public/static/langs/da.json | 4 +- web/public/static/langs/de.json | 4 +- web/public/static/langs/en.json | 14 +- web/public/static/langs/es.json | 4 +- web/public/static/langs/fr.json | 4 +- web/public/static/langs/hu.json | 2 +- web/public/static/langs/id.json | 4 +- web/public/static/langs/it.json | 2 +- web/public/static/langs/ja.json | 4 +- web/public/static/langs/ko.json | 2 +- web/public/static/langs/nb_NO.json | 2 +- web/public/static/langs/nl.json | 4 +- web/public/static/langs/pl.json | 4 +- web/public/static/langs/pt.json | 2 +- web/public/static/langs/pt_BR.json | 2 +- web/public/static/langs/ru.json | 4 +- web/public/static/langs/sv.json | 4 +- web/public/static/langs/tr.json | 4 +- web/public/static/langs/uk.json | 2 +- web/public/static/langs/zh_Hans.json | 4 +- web/public/static/langs/zh_Hant.json | 2 +- web/src/app/AccountApi.js | 39 +++++- web/src/app/utils.js | 1 + web/src/components/Account.js | 176 +++++++++++++++++++++++--- web/src/components/SubscribeDialog.js | 2 +- web/src/components/UpgradeDialog.js | 2 - 34 files changed, 311 insertions(+), 143 deletions(-) diff --git a/server/server.go b/server/server.go index b474da7..ce87f97 100644 --- a/server/server.go +++ b/server/server.go @@ -455,6 +455,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberAdd))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPhonePath { return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberVerify))(w, r, v) + } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath { + return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberDelete))(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath { return s.handleStats(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath { @@ -692,6 +694,9 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e } else if call != "" && !vrate.CallAllowed() { return nil, errHTTPTooManyRequestsLimitCalls.With(t) } + + // FIXME check allowed phone numbers + if m.PollID != "" { m = newPollRequestMessage(t.ID, m.PollID) } diff --git a/server/server_account.go b/server/server_account.go index a323bfe..c5517d6 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -146,13 +146,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis return err } if len(phoneNumbers) > 0 { - response.PhoneNumbers = make([]*apiAccountPhoneNumberResponse, 0) - for _, p := range phoneNumbers { - response.PhoneNumbers = append(response.PhoneNumbers, &apiAccountPhoneNumberResponse{ - Number: p.Number, - Verified: p.Verified, - }) - } + response.PhoneNumbers = phoneNumbers } } else { response.Username = user.Everyone @@ -542,19 +536,15 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ } else if u.IsUser() && u.Tier.CallLimit == 0 { return errHTTPUnauthorized } - // Actually add the unverified number, and send verification - logvr(v, r). - Tag(tagAccount). - Fields(log.Context{ - "number": req.Number, - }). - Debug("Adding phone number, and sending verification") - if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil { - if err == user.ErrPhoneNumberExists { - return errHTTPConflictPhoneNumberExists - } + // Check if phone number exists + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + if err != nil { return err + } else if util.Contains(phoneNumbers, req.Number) { + return errHTTPConflictPhoneNumberExists } + // Actually add the unverified number, and send verification + logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification") if err := s.verifyPhone(v, r, req.Number); err != nil { return err } @@ -570,31 +560,27 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R if !phoneNumberRegex.MatchString(req.Number) { return errHTTPBadRequestPhoneNumberInvalid } - // Get phone numbers, and check if it's in the list - phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) - if err != nil { - return err - } - found := false - for _, phoneNumber := range phoneNumbers { - if phoneNumber.Number == req.Number && !phoneNumber.Verified { - found = true - break - } - } - if !found { - return errHTTPBadRequestPhoneNumberInvalid - } if err := s.checkVerifyPhone(v, r, req.Number, req.Code); err != nil { return err } - logvr(v, r). - Tag(tagAccount). - Fields(log.Context{ - "number": req.Number, - }). - Debug("Marking phone number as verified") - if err := s.userManager.MarkPhoneNumberVerified(u.ID, req.Number); err != nil { + logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified") + if err := s.userManager.AddPhoneNumber(u.ID, req.Number); err != nil { + return err + } + return s.writeJSON(w, newSuccessResponse()) +} + +func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := v.User() + req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } + if !phoneNumberRegex.MatchString(req.Number) { + return errHTTPBadRequestPhoneNumberInvalid + } + logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Deleting phone number") + if err := s.userManager.DeletePhoneNumber(u.ID, req.Number); err != nil { return err } return s.writeJSON(w, newSuccessResponse()) diff --git a/server/types.go b/server/types.go index 9015a00..d660e71 100644 --- a/server/types.go +++ b/server/types.go @@ -282,11 +282,6 @@ type apiAccountPhoneNumberRequest struct { Code string `json:"code,omitempty"` // Only supplied in "verify" call } -type apiAccountPhoneNumberResponse struct { - Number string `json:"number"` - Verified bool `json:"verified"` -} - type apiAccountTier struct { Code string `json:"code"` Name string `json:"name"` @@ -336,19 +331,19 @@ type apiAccountBilling struct { } type apiAccountResponse struct { - Username string `json:"username"` - Role string `json:"role,omitempty"` - SyncTopic string `json:"sync_topic,omitempty"` - Language string `json:"language,omitempty"` - Notification *user.NotificationPrefs `json:"notification,omitempty"` - Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` - Reservations []*apiAccountReservation `json:"reservations,omitempty"` - Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` - PhoneNumbers []*apiAccountPhoneNumberResponse `json:"phone_numbers,omitempty"` - Tier *apiAccountTier `json:"tier,omitempty"` - Limits *apiAccountLimits `json:"limits,omitempty"` - Stats *apiAccountStats `json:"stats,omitempty"` - Billing *apiAccountBilling `json:"billing,omitempty"` + Username string `json:"username"` + Role string `json:"role,omitempty"` + SyncTopic string `json:"sync_topic,omitempty"` + Language string `json:"language,omitempty"` + Notification *user.NotificationPrefs `json:"notification,omitempty"` + Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` + Reservations []*apiAccountReservation `json:"reservations,omitempty"` + Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` + PhoneNumbers []string `json:"phone_numbers,omitempty"` + Tier *apiAccountTier `json:"tier,omitempty"` + Limits *apiAccountLimits `json:"limits,omitempty"` + Stats *apiAccountStats `json:"stats,omitempty"` + Billing *apiAccountBilling `json:"billing,omitempty"` } type apiAccountReservationRequest struct { diff --git a/user/manager.go b/user/manager.go index 7c179cf..7a03095 100644 --- a/user/manager.go +++ b/user/manager.go @@ -115,7 +115,6 @@ const ( CREATE TABLE IF NOT EXISTS user_phone ( user_id TEXT NOT NULL, phone_number TEXT NOT NULL, - verified INT NOT NULL, PRIMARY KEY (user_id, phone_number), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); @@ -268,9 +267,9 @@ const ( ) ` - selectPhoneNumbersQuery = `SELECT phone_number, verified FROM user_phone WHERE user_id = ?` - insertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number, verified) VALUES (?, ?, 0)` - updatePhoneNumberVerifiedQuery = `UPDATE user_phone SET verified=1 WHERE user_id = ? AND phone_number = ?` + selectPhoneNumbersQuery = `SELECT phone_number FROM user_phone WHERE user_id = ?` + insertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)` + deletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` insertTierQuery = ` INSERT INTO tier (id, code, name, messages_limit, messages_expiry_duration, emails_limit, calls_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, attachment_bandwidth_limit, stripe_monthly_price_id, stripe_yearly_price_id) @@ -414,7 +413,6 @@ const ( CREATE TABLE IF NOT EXISTS user_phone ( user_id TEXT NOT NULL, phone_number TEXT NOT NULL, - verified INT NOT NULL, PRIMARY KEY (user_id, phone_number), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); @@ -648,13 +646,14 @@ func (a *Manager) RemoveExpiredTokens() error { return nil } -func (a *Manager) PhoneNumbers(userID string) ([]*PhoneNumber, error) { +// PhoneNumbers returns all phone numbers for the user with the given user ID +func (a *Manager) PhoneNumbers(userID string) ([]string, error) { rows, err := a.db.Query(selectPhoneNumbersQuery, userID) if err != nil { return nil, err } defer rows.Close() - phoneNumbers := make([]*PhoneNumber, 0) + phoneNumbers := make([]string, 0) for { phoneNumber, err := a.readPhoneNumber(rows) if err == ErrPhoneNumberNotFound { @@ -667,23 +666,20 @@ func (a *Manager) PhoneNumbers(userID string) ([]*PhoneNumber, error) { return phoneNumbers, nil } -func (a *Manager) readPhoneNumber(rows *sql.Rows) (*PhoneNumber, error) { +func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) { var phoneNumber string - var verified bool if !rows.Next() { - return nil, ErrPhoneNumberNotFound + return "", ErrPhoneNumberNotFound } - if err := rows.Scan(&phoneNumber, &verified); err != nil { - return nil, err + if err := rows.Scan(&phoneNumber); err != nil { + return "", err } else if err := rows.Err(); err != nil { - return nil, err + return "", err } - return &PhoneNumber{ - Number: phoneNumber, - Verified: verified, - }, nil + return phoneNumber, nil } +// AddPhoneNumber adds a phone number to the user with the given user ID func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error { if _, err := a.db.Exec(insertPhoneNumberQuery, userID, phoneNumber); err != nil { if sqliteErr, ok := err.(sqlite3.Error); ok && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique { @@ -694,11 +690,10 @@ func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error { return nil } -func (a *Manager) MarkPhoneNumberVerified(userID string, phoneNumber string) error { - if _, err := a.db.Exec(updatePhoneNumberVerifiedQuery, userID, phoneNumber); err != nil { - return err - } - return nil +// DeletePhoneNumber deletes a phone number from the user with the given user ID +func (a *Manager) DeletePhoneNumber(userID string, phoneNumber string) error { + _, err := a.db.Exec(deletePhoneNumberQuery, userID, phoneNumber) + return err } // RemoveDeletedUsers deletes all users that have been marked deleted for diff --git a/user/types.go b/user/types.go index 51a2b3f..1189578 100644 --- a/user/types.go +++ b/user/types.go @@ -71,11 +71,6 @@ type TokenUpdate struct { LastOrigin netip.Addr } -type PhoneNumber struct { - Number string - Verified bool -} - // Prefs represents a user's configuration settings type Prefs struct { Language *string `json:"language,omitempty"` diff --git a/web/public/static/langs/ar.json b/web/public/static/langs/ar.json index a3919ff..0c9fcc7 100644 --- a/web/public/static/langs/ar.json +++ b/web/public/static/langs/ar.json @@ -152,7 +152,7 @@ "publish_dialog_chip_delay_label": "تأخير التسليم", "subscribe_dialog_login_description": "هذا الموضوع محمي بكلمة مرور. الرجاء إدخال اسم المستخدم وكلمة المرور للاشتراك.", "subscribe_dialog_subscribe_button_cancel": "إلغاء", - "subscribe_dialog_login_button_back": "العودة", + "common_back": "العودة", "prefs_notifications_sound_play": "تشغيل الصوت المحدد", "prefs_notifications_min_priority_title": "أولوية دنيا", "prefs_notifications_min_priority_max_only": "الأولوية القصوى فقط", @@ -225,7 +225,7 @@ "account_tokens_table_expires_header": "تنتهي مدة صلاحيته في", "account_tokens_table_never_expires": "لا تنتهي صلاحيتها أبدا", "account_tokens_table_current_session": "جلسة المتصفح الحالية", - "account_tokens_table_copy_to_clipboard": "انسخ إلى الحافظة", + "common_copy_to_clipboard": "انسخ إلى الحافظة", "account_tokens_table_cannot_delete_or_edit": "لا يمكن تحرير أو حذف الرمز المميز للجلسة الحالية", "account_tokens_table_create_token_button": "إنشاء رمز مميز للوصول", "account_tokens_table_last_origin_tooltip": "من عنوان IP {{ip}}، انقر للبحث", diff --git a/web/public/static/langs/bg.json b/web/public/static/langs/bg.json index 8178c46..a040b01 100644 --- a/web/public/static/langs/bg.json +++ b/web/public/static/langs/bg.json @@ -104,7 +104,7 @@ "subscribe_dialog_subscribe_topic_placeholder": "Име на темата, напр. phils_alerts", "subscribe_dialog_subscribe_use_another_label": "Използване на друг сървър", "subscribe_dialog_login_username_label": "Потребител, напр. phil", - "subscribe_dialog_login_button_back": "Назад", + "common_back": "Назад", "subscribe_dialog_subscribe_button_cancel": "Отказ", "subscribe_dialog_login_description": "Темата е защитена. За да се абонирате въведете потребител и парола.", "subscribe_dialog_subscribe_button_subscribe": "Абониране", diff --git a/web/public/static/langs/cs.json b/web/public/static/langs/cs.json index f882658..aeff195 100644 --- a/web/public/static/langs/cs.json +++ b/web/public/static/langs/cs.json @@ -91,7 +91,7 @@ "subscribe_dialog_subscribe_button_subscribe": "Přihlásit odběr", "subscribe_dialog_login_username_label": "Uživatelské jméno, např. phil", "subscribe_dialog_login_password_label": "Heslo", - "subscribe_dialog_login_button_back": "Zpět", + "common_back": "Zpět", "subscribe_dialog_login_button_login": "Přihlásit se", "subscribe_dialog_error_user_not_authorized": "Uživatel {{username}} není autorizován", "subscribe_dialog_error_user_anonymous": "anonymně", @@ -305,7 +305,7 @@ "account_tokens_table_expires_header": "Vyprší", "account_tokens_table_never_expires": "Nikdy nevyprší", "account_tokens_table_current_session": "Současná relace prohlížeče", - "account_tokens_table_copy_to_clipboard": "Kopírování do schránky", + "common_copy_to_clipboard": "Kopírování do schránky", "account_tokens_table_label_header": "Popisek", "account_tokens_table_cannot_delete_or_edit": "Nelze upravit nebo odstranit aktuální token relace", "account_tokens_table_create_token_button": "Vytvořit přístupový token", diff --git a/web/public/static/langs/da.json b/web/public/static/langs/da.json index d60c56c..c7477df 100644 --- a/web/public/static/langs/da.json +++ b/web/public/static/langs/da.json @@ -91,7 +91,7 @@ "publish_dialog_delay_label": "Forsinkelse", "publish_dialog_button_send": "Send", "subscribe_dialog_subscribe_button_subscribe": "Tilmeld", - "subscribe_dialog_login_button_back": "Tilbage", + "common_back": "Tilbage", "subscribe_dialog_login_username_label": "Brugernavn, f.eks. phil", "account_basics_title": "Konto", "subscribe_dialog_error_topic_already_reserved": "Emnet er allerede reserveret", @@ -209,7 +209,7 @@ "subscribe_dialog_subscribe_use_another_label": "Brug en anden server", "account_basics_tier_upgrade_button": "Opgrader til Pro", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} daglige beskeder", - "account_tokens_table_copy_to_clipboard": "Kopier til udklipsholder", + "common_copy_to_clipboard": "Kopier til udklipsholder", "prefs_reservations_edit_button": "Rediger emneadgang", "account_upgrade_dialog_title": "Skift kontoniveau", "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} reserverede emner", diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index 88a5c14..e3f5592 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -94,7 +94,7 @@ "publish_dialog_delay_placeholder": "Auslieferung verzögern, z.B. {{unixTimestamp}}, {{relativeTime}}, oder \"{{naturalLanguage}}\" (nur Englisch)", "prefs_appearance_title": "Darstellung", "subscribe_dialog_login_password_label": "Kennwort", - "subscribe_dialog_login_button_back": "Zurück", + "common_back": "Zurück", "publish_dialog_chip_attach_url_label": "Datei von URL anhängen", "publish_dialog_chip_delay_label": "Auslieferung verzögern", "publish_dialog_chip_topic_label": "Thema ändern", @@ -284,7 +284,7 @@ "account_tokens_table_expires_header": "Verfällt", "account_tokens_table_never_expires": "Verfällt nie", "account_tokens_table_current_session": "Aktuelle Browser-Sitzung", - "account_tokens_table_copy_to_clipboard": "In die Zwischenablage kopieren", + "common_copy_to_clipboard": "In die Zwischenablage kopieren", "account_tokens_table_copied_to_clipboard": "Access-Token kopiert", "account_tokens_table_cannot_delete_or_edit": "Aktuelles Token kann nicht bearbeitet oder gelöscht werden", "account_tokens_table_create_token_button": "Access-Token erzeugen", diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 86330f1..7d8affc 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -2,6 +2,8 @@ "common_cancel": "Cancel", "common_save": "Save", "common_add": "Add", + "common_back": "Back", + "common_copy_to_clipboard": "Copy to clipboard", "signup_title": "Create a ntfy account", "signup_form_username": "Username", "signup_form_password": "Password", @@ -169,7 +171,6 @@ "subscribe_dialog_login_description": "This topic is password-protected. Please enter username and password to subscribe.", "subscribe_dialog_login_username_label": "Username, e.g. phil", "subscribe_dialog_login_password_label": "Password", - "subscribe_dialog_login_button_back": "Back", "subscribe_dialog_login_button_login": "Login", "subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized", "subscribe_dialog_error_topic_already_reserved": "Topic already reserved", @@ -187,7 +188,17 @@ "account_basics_password_dialog_button_submit": "Change password", "account_basics_password_dialog_current_password_incorrect": "Password incorrect", "account_basics_phone_numbers_title": "Phone numbers", + "account_basics_phone_numbers_dialog_description": "To use the call notification feature, you need to add and verify at least one phone number. Adding it will send a verification SMS to your phone.", "account_basics_phone_numbers_description": "For phone call notifications", + "account_basics_phone_numbers_no_phone_numbers_yet": "No phone numbers yet", + "account_basics_phone_numbers_copied_to_clipboard": "Phone number copied to clipboard", + "account_basics_phone_numbers_dialog_title": "Add phone number", + "account_basics_phone_numbers_dialog_number_label": "Phone number", + "account_basics_phone_numbers_dialog_number_placeholder": "e.g. +1222333444", + "account_basics_phone_numbers_dialog_send_verification_button": "Send verification", + "account_basics_phone_numbers_dialog_code_label": "Verification code", + "account_basics_phone_numbers_dialog_code_placeholder": "e.g. 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code", "account_usage_title": "Usage", "account_usage_of_limit": "of {{limit}}", "account_usage_unlimited": "Unlimited", @@ -265,7 +276,6 @@ "account_tokens_table_expires_header": "Expires", "account_tokens_table_never_expires": "Never expires", "account_tokens_table_current_session": "Current browser session", - "account_tokens_table_copy_to_clipboard": "Copy to clipboard", "account_tokens_table_copied_to_clipboard": "Access token copied", "account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token", "account_tokens_table_create_token_button": "Create access token", diff --git a/web/public/static/langs/es.json b/web/public/static/langs/es.json index 0fc7c3a..3166a52 100644 --- a/web/public/static/langs/es.json +++ b/web/public/static/langs/es.json @@ -81,7 +81,7 @@ "subscribe_dialog_login_description": "Este tópico está protegido por contraseña. Por favor, introduzca su nombre de usuario y contraseña para suscribirse.", "subscribe_dialog_login_username_label": "Nombre de usuario, ej. phil", "subscribe_dialog_login_password_label": "Contraseña", - "subscribe_dialog_login_button_back": "Volver", + "common_back": "Volver", "subscribe_dialog_login_button_login": "Iniciar sesión", "subscribe_dialog_error_user_not_authorized": "Usuario {{username}} no autorizado", "subscribe_dialog_error_user_anonymous": "anónimo", @@ -257,7 +257,7 @@ "account_tokens_table_expires_header": "Expira", "account_tokens_table_never_expires": "Nunca expira", "account_tokens_table_current_session": "Sesión del navegador actual", - "account_tokens_table_copy_to_clipboard": "Copiar al portapapeles", + "common_copy_to_clipboard": "Copiar al portapapeles", "account_tokens_table_copied_to_clipboard": "Token de acceso copiado", "account_tokens_table_cannot_delete_or_edit": "No se puede editar ni eliminar el token de sesión actual", "account_tokens_table_create_token_button": "Crear token de acceso", diff --git a/web/public/static/langs/fr.json b/web/public/static/langs/fr.json index a24ece0..ba71eb4 100644 --- a/web/public/static/langs/fr.json +++ b/web/public/static/langs/fr.json @@ -106,7 +106,7 @@ "prefs_notifications_title": "Notifications", "prefs_notifications_delete_after_title": "Supprimer les notifications", "prefs_users_add_button": "Ajouter un utilisateur", - "subscribe_dialog_login_button_back": "Retour", + "common_back": "Retour", "subscribe_dialog_error_user_anonymous": "anonyme", "prefs_notifications_sound_no_sound": "Aucun son", "prefs_notifications_min_priority_title": "Priorité minimum", @@ -293,7 +293,7 @@ "account_tokens_table_expires_header": "Expire", "account_tokens_table_never_expires": "N'expire jamais", "account_tokens_table_current_session": "Session de navigation actuelle", - "account_tokens_table_copy_to_clipboard": "Copier dans le presse-papier", + "common_copy_to_clipboard": "Copier dans le presse-papier", "account_tokens_table_copied_to_clipboard": "Jeton d'accès copié", "account_tokens_table_create_token_button": "Créer un jeton d'accès", "account_tokens_table_last_origin_tooltip": "Depuis l'adresse IP {{ip}}, cliquer pour rechercher", diff --git a/web/public/static/langs/hu.json b/web/public/static/langs/hu.json index 975d8d9..b52e3a4 100644 --- a/web/public/static/langs/hu.json +++ b/web/public/static/langs/hu.json @@ -84,7 +84,7 @@ "subscribe_dialog_login_description": "Ez a téma jelszóval védett. Jelentkezz be a feliratkozáshoz.", "subscribe_dialog_login_username_label": "Felhasználónév, pl: jozsi", "subscribe_dialog_login_password_label": "Jelszó", - "subscribe_dialog_login_button_back": "Vissza", + "common_back": "Vissza", "subscribe_dialog_login_button_login": "Belépés", "subscribe_dialog_error_user_anonymous": "névtelen", "subscribe_dialog_error_user_not_authorized": "A(z) {{username}} felhasználónak nincs hozzáférése", diff --git a/web/public/static/langs/id.json b/web/public/static/langs/id.json index 027653b..51e6a98 100644 --- a/web/public/static/langs/id.json +++ b/web/public/static/langs/id.json @@ -116,7 +116,7 @@ "common_save": "Simpan", "prefs_appearance_title": "Tampilan", "subscribe_dialog_login_password_label": "Kata sandi", - "subscribe_dialog_login_button_back": "Kembali", + "common_back": "Kembali", "prefs_notifications_sound_title": "Suara notifikasi", "prefs_notifications_min_priority_low_and_higher": "Prioritas rendah dan lebih tinggi", "prefs_notifications_min_priority_default_and_higher": "Prioritas bawaan dan lebih tinggi", @@ -278,7 +278,7 @@ "account_tokens_table_expires_header": "Kedaluwarsa", "account_tokens_table_never_expires": "Tidak pernah kedaluwarsa", "account_tokens_table_current_session": "Sesi peramban saat ini", - "account_tokens_table_copy_to_clipboard": "Salin ke papan klip", + "common_copy_to_clipboard": "Salin ke papan klip", "account_tokens_table_copied_to_clipboard": "Token akses disalin", "account_tokens_table_cannot_delete_or_edit": "Tidak dapat menyunting atau menghapus token sesi saat ini", "account_tokens_table_create_token_button": "Buat token akses", diff --git a/web/public/static/langs/it.json b/web/public/static/langs/it.json index 87ea04a..a62d31f 100644 --- a/web/public/static/langs/it.json +++ b/web/public/static/langs/it.json @@ -178,7 +178,7 @@ "prefs_notifications_sound_play": "Riproduci il suono selezionato", "prefs_notifications_min_priority_title": "Priorità minima", "subscribe_dialog_login_description": "Questo argomento è protetto da password. Per favore inserisci username e password per iscriverti.", - "subscribe_dialog_login_button_back": "Indietro", + "common_back": "Indietro", "subscribe_dialog_error_user_not_authorized": "Utente {{username}} non autorizzato", "prefs_notifications_title": "Notifiche", "prefs_notifications_delete_after_title": "Elimina le notifiche", diff --git a/web/public/static/langs/ja.json b/web/public/static/langs/ja.json index 1b24ec0..7eb1c7d 100644 --- a/web/public/static/langs/ja.json +++ b/web/public/static/langs/ja.json @@ -20,7 +20,7 @@ "subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。", "subscribe_dialog_login_username_label": "ユーザー名, 例) phil", "subscribe_dialog_login_password_label": "パスワード", - "subscribe_dialog_login_button_back": "戻る", + "common_back": "戻る", "subscribe_dialog_login_button_login": "ログイン", "prefs_notifications_min_priority_high_and_higher": "優先度高 およびそれ以上", "prefs_notifications_min_priority_max_only": "優先度最高のみ", @@ -258,7 +258,7 @@ "account_tokens_table_expires_header": "期限", "account_tokens_table_never_expires": "無期限", "account_tokens_table_current_session": "現在のブラウザセッション", - "account_tokens_table_copy_to_clipboard": "クリップボードにコピー", + "common_copy_to_clipboard": "クリップボードにコピー", "account_tokens_table_copied_to_clipboard": "アクセストークンをコピーしました", "account_tokens_table_cannot_delete_or_edit": "現在のセッショントークンは編集または削除できません", "account_tokens_table_create_token_button": "アクセストークンを生成", diff --git a/web/public/static/langs/ko.json b/web/public/static/langs/ko.json index 67c3128..2e46c7a 100644 --- a/web/public/static/langs/ko.json +++ b/web/public/static/langs/ko.json @@ -93,7 +93,7 @@ "subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다", "subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil", "subscribe_dialog_login_password_label": "비밀번호", - "subscribe_dialog_login_button_back": "뒤로가기", + "common_back": "뒤로가기", "subscribe_dialog_login_button_login": "로그인", "prefs_notifications_title": "알림", "prefs_notifications_sound_title": "알림 효과음", diff --git a/web/public/static/langs/nb_NO.json b/web/public/static/langs/nb_NO.json index 312791d..0dd9571 100644 --- a/web/public/static/langs/nb_NO.json +++ b/web/public/static/langs/nb_NO.json @@ -113,7 +113,7 @@ "prefs_notifications_delete_after_one_week_description": "Merknader slettes automatisk etter én uke", "prefs_notifications_delete_after_one_month_description": "Merknader slettes automatisk etter én måned", "priority_min": "min.", - "subscribe_dialog_login_button_back": "Tilbake", + "common_back": "Tilbake", "prefs_notifications_delete_after_three_hours": "Etter tre timer", "prefs_users_table_base_url_header": "Tjeneste-nettadresse", "common_cancel": "Avbryt", diff --git a/web/public/static/langs/nl.json b/web/public/static/langs/nl.json index b9ac8e1..ca7a2a1 100644 --- a/web/public/static/langs/nl.json +++ b/web/public/static/langs/nl.json @@ -140,7 +140,7 @@ "subscribe_dialog_subscribe_title": "Onderwerp abonneren", "subscribe_dialog_subscribe_description": "Onderwerpen zijn mogelijk niet beschermd met een wachtwoord, kies daarom een moeilijk te raden naam. Na abonneren kun je notificaties via PUT/POST sturen.", "subscribe_dialog_login_password_label": "Wachtwoord", - "subscribe_dialog_login_button_back": "Terug", + "common_back": "Terug", "subscribe_dialog_login_button_login": "Aanmelden", "subscribe_dialog_error_user_not_authorized": "Gebruiker {{username}} heeft geen toegang", "subscribe_dialog_error_user_anonymous": "anoniem", @@ -331,7 +331,7 @@ "account_upgrade_dialog_button_cancel_subscription": "Abonnement opzeggen", "account_tokens_table_last_access_header": "Laatste toegang", "account_tokens_table_expires_header": "Verloopt op", - "account_tokens_table_copy_to_clipboard": "Kopieer naar klembord", + "common_copy_to_clipboard": "Kopieer naar klembord", "account_tokens_table_copied_to_clipboard": "Toegangstoken gekopieerd", "account_tokens_delete_dialog_submit_button": "Token definitief verwijderen", "prefs_users_description_no_sync": "Gebruikers en wachtwoorden worden niet gesynchroniseerd met uw account.", diff --git a/web/public/static/langs/pl.json b/web/public/static/langs/pl.json index 5e6bcbe..9dea2b8 100644 --- a/web/public/static/langs/pl.json +++ b/web/public/static/langs/pl.json @@ -107,7 +107,7 @@ "subscribe_dialog_login_username_label": "Nazwa użytkownika, np. phil", "subscribe_dialog_login_password_label": "Hasło", "publish_dialog_button_cancel": "Anuluj", - "subscribe_dialog_login_button_back": "Powrót", + "common_back": "Powrót", "subscribe_dialog_login_button_login": "Zaloguj się", "subscribe_dialog_error_user_not_authorized": "Użytkownik {{username}} nie ma uprawnień", "subscribe_dialog_error_user_anonymous": "anonim", @@ -253,7 +253,7 @@ "account_tokens_table_expires_header": "Termin ważności", "account_tokens_table_never_expires": "Bezterminowy", "account_tokens_table_current_session": "Aktualna sesja przeglądarki", - "account_tokens_table_copy_to_clipboard": "Kopiuj do schowka", + "common_copy_to_clipboard": "Kopiuj do schowka", "account_tokens_table_copied_to_clipboard": "Token został skopiowany", "account_tokens_table_cannot_delete_or_edit": "Nie można edytować ani usunąć tokenu aktualnej sesji", "account_tokens_table_create_token_button": "Utwórz token dostępowy", diff --git a/web/public/static/langs/pt.json b/web/public/static/langs/pt.json index 196baf4..bf753c9 100644 --- a/web/public/static/langs/pt.json +++ b/web/public/static/langs/pt.json @@ -144,7 +144,7 @@ "subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.", "subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"", "subscribe_dialog_login_password_label": "Palavra-passe", - "subscribe_dialog_login_button_back": "Voltar", + "common_back": "Voltar", "subscribe_dialog_login_button_login": "Autenticar", "subscribe_dialog_error_user_anonymous": "anónimo", "prefs_notifications_title": "Notificações", diff --git a/web/public/static/langs/pt_BR.json b/web/public/static/langs/pt_BR.json index 79622be..acf5bca 100644 --- a/web/public/static/langs/pt_BR.json +++ b/web/public/static/langs/pt_BR.json @@ -93,7 +93,7 @@ "prefs_notifications_min_priority_low_and_higher": "Baixa prioridade e acima", "prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima", "subscribe_dialog_login_password_label": "Senha", - "subscribe_dialog_login_button_back": "Voltar", + "common_back": "Voltar", "prefs_notifications_min_priority_high_and_higher": "Alta prioridade e acima", "prefs_notifications_min_priority_max_only": "Apenas prioridade máxima", "prefs_notifications_delete_after_title": "Apagar notificações", diff --git a/web/public/static/langs/ru.json b/web/public/static/langs/ru.json index 42025e4..9633d97 100644 --- a/web/public/static/langs/ru.json +++ b/web/public/static/langs/ru.json @@ -98,7 +98,7 @@ "subscribe_dialog_login_description": "Эта тема защищена паролем. Пожалуйста, введите имя пользователя и пароль, чтобы подписаться.", "subscribe_dialog_login_username_label": "Имя пользователя. Например, phil", "subscribe_dialog_login_password_label": "Пароль", - "subscribe_dialog_login_button_back": "Назад", + "common_back": "Назад", "subscribe_dialog_login_button_login": "Войти", "subscribe_dialog_error_user_not_authorized": "Пользователь {{username}} не авторизован", "subscribe_dialog_error_user_anonymous": "анонимный пользователь", @@ -206,7 +206,7 @@ "account_basics_tier_free": "Бесплатный", "account_tokens_dialog_title_create": "Создать токен доступа", "account_tokens_dialog_title_delete": "Удалить токен доступа", - "account_tokens_table_copy_to_clipboard": "Скопировать в буфер обмена", + "common_copy_to_clipboard": "Скопировать в буфер обмена", "account_tokens_dialog_button_cancel": "Отмена", "account_tokens_dialog_expires_unchanged": "Оставить срок истечения без изменений", "account_tokens_dialog_expires_x_days": "Токен истекает через {{days}} дней", diff --git a/web/public/static/langs/sv.json b/web/public/static/langs/sv.json index 9e9dfc2..31e809c 100644 --- a/web/public/static/langs/sv.json +++ b/web/public/static/langs/sv.json @@ -95,14 +95,14 @@ "publish_dialog_email_placeholder": "Adress att vidarebefordra meddelandet till, t.ex. phil@example.com", "publish_dialog_details_examples_description": "Exempel och en detaljerad beskrivning av alla sändningsfunktioner finns i dokumentationen .", "publish_dialog_button_send": "Skicka", - "subscribe_dialog_login_button_back": "Tillbaka", + "common_back": "Tillbaka", "account_basics_tier_free": "Gratis", "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserverat ämne", "account_delete_title": "Ta bort konto", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} dagliga meddelanden", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} dagligt e-postmeddelande", "account_upgrade_dialog_button_cancel": "Avbryt", - "account_tokens_table_copy_to_clipboard": "Kopiera till urklipp", + "common_copy_to_clipboard": "Kopiera till urklipp", "account_tokens_table_copied_to_clipboard": "Åtkomsttoken kopierat", "account_tokens_description": "Använd åtkomsttoken när du publicerar och prenumererar via ntfy API, så att du inte behöver skicka dina kontouppgifter. Läs mer i dokumentationen.", "account_tokens_table_create_token_button": "Skapa åtkomsttoken", diff --git a/web/public/static/langs/tr.json b/web/public/static/langs/tr.json index 8bdb88d..3eccda8 100644 --- a/web/public/static/langs/tr.json +++ b/web/public/static/langs/tr.json @@ -34,7 +34,7 @@ "subscribe_dialog_login_description": "Bu konu parola korumalı. Abone olmak için lütfen kullanıcı adı ve parola girin.", "subscribe_dialog_login_username_label": "Kullanıcı adı, örn. phil", "subscribe_dialog_login_password_label": "Parola", - "subscribe_dialog_login_button_back": "Geri", + "common_back": "Geri", "subscribe_dialog_login_button_login": "Oturum aç", "subscribe_dialog_error_user_not_authorized": "{{username}} kullanıcısı yetkili değil", "subscribe_dialog_error_user_anonymous": "anonim", @@ -268,7 +268,7 @@ "account_tokens_table_token_header": "Belirteç", "account_tokens_table_label_header": "Etiket", "account_tokens_table_current_session": "Geçerli tarayıcı oturumu", - "account_tokens_table_copy_to_clipboard": "Panoya kopyala", + "common_copy_to_clipboard": "Panoya kopyala", "account_tokens_table_copied_to_clipboard": "Erişim belirteci kopyalandı", "account_tokens_table_cannot_delete_or_edit": "Geçerli oturum belirteci düzenlenemez veya silinemez", "account_tokens_table_create_token_button": "Erişim belirteci oluştur", diff --git a/web/public/static/langs/uk.json b/web/public/static/langs/uk.json index 686a3d3..8683769 100644 --- a/web/public/static/langs/uk.json +++ b/web/public/static/langs/uk.json @@ -53,7 +53,7 @@ "subscribe_dialog_subscribe_use_another_label": "Використовувати інший сервер", "subscribe_dialog_subscribe_base_url_label": "URL служби", "subscribe_dialog_login_password_label": "Пароль", - "subscribe_dialog_login_button_back": "Назад", + "common_back": "Назад", "subscribe_dialog_error_user_not_authorized": "{{username}} користувач не авторизований", "prefs_notifications_sound_description_none": "Сповіщення не відтворюють жодного звуку при надходженні", "prefs_notifications_sound_description_some": "Сповіщення відтворюють звук {{sound}}", diff --git a/web/public/static/langs/zh_Hans.json b/web/public/static/langs/zh_Hans.json index 4da4328..2db95f5 100644 --- a/web/public/static/langs/zh_Hans.json +++ b/web/public/static/langs/zh_Hans.json @@ -103,7 +103,7 @@ "subscribe_dialog_login_description": "本主题受密码保护,请输入用户名和密码进行订阅。", "subscribe_dialog_login_username_label": "用户名,例如 phil", "subscribe_dialog_login_password_label": "密码", - "subscribe_dialog_login_button_back": "返回", + "common_back": "返回", "subscribe_dialog_login_button_login": "登录", "subscribe_dialog_error_user_not_authorized": "未授权 {{username}} 用户", "subscribe_dialog_error_user_anonymous": "匿名", @@ -333,7 +333,7 @@ "account_tokens_table_expires_header": "过期", "account_tokens_table_never_expires": "永不过期", "account_tokens_table_current_session": "当前浏览器会话", - "account_tokens_table_copy_to_clipboard": "复制到剪贴板", + "common_copy_to_clipboard": "复制到剪贴板", "account_tokens_table_copied_to_clipboard": "已复制访问令牌", "account_tokens_table_cannot_delete_or_edit": "无法编辑或删除当前会话令牌", "account_tokens_table_create_token_button": "创建访问令牌", diff --git a/web/public/static/langs/zh_Hant.json b/web/public/static/langs/zh_Hant.json index c1b4de8..aafc28e 100644 --- a/web/public/static/langs/zh_Hant.json +++ b/web/public/static/langs/zh_Hant.json @@ -70,7 +70,7 @@ "subscribe_dialog_subscribe_button_subscribe": "訂閱", "emoji_picker_search_clear": "清除", "subscribe_dialog_login_password_label": "密碼", - "subscribe_dialog_login_button_back": "返回", + "common_back": "返回", "subscribe_dialog_login_button_login": "登入", "prefs_notifications_delete_after_never": "從不", "prefs_users_add_button": "新增使用者", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 243286b..21b3f81 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -1,7 +1,7 @@ import { accountBillingPortalUrl, accountBillingSubscriptionUrl, - accountPasswordUrl, + accountPasswordUrl, accountPhoneUrl, accountReservationSingleUrl, accountReservationUrl, accountSettingsUrl, @@ -299,6 +299,43 @@ class AccountApi { return await response.json(); // May throw SyntaxError } + async verifyPhone(phoneNumber) { + const url = accountPhoneUrl(config.base_url); + console.log(`[AccountApi] Sending phone verification ${url}`); + await fetchOrThrow(url, { + method: "PUT", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + number: phoneNumber + }) + }); + } + + async checkVerifyPhone(phoneNumber, code) { + const url = accountPhoneUrl(config.base_url); + console.log(`[AccountApi] Checking phone verification code ${url}`); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + number: phoneNumber, + code: code + }) + }); + } + + async deletePhoneNumber(phoneNumber, code) { + const url = accountPhoneUrl(config.base_url); + console.log(`[AccountApi] Deleting phone number ${url}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + number: phoneNumber + }) + }); + } + async sync() { try { if (!session.token()) { diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 25b4a45..6e04491 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -27,6 +27,7 @@ export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reserva export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`; export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; +export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; diff --git a/web/src/components/Account.js b/web/src/components/Account.js index b5294cd..4c19a29 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -325,37 +325,183 @@ const AccountType = () => { const PhoneNumbers = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const [snackOpen, setSnackOpen] = useState(false); const labelId = "prefPhoneNumbers"; - const handleAdd = () => { - + const handleDialogOpen = () => { + setDialogKey(prev => prev+1); + setDialogOpen(true); }; - const handleClick = () => { - + const handleDialogClose = () => { + setDialogOpen(false); }; - const handleDelete = () => { - + const handleCopy = (phoneNumber) => { + navigator.clipboard.writeText(phoneNumber); + setSnackOpen(true); }; + const handleDelete = async (phoneNumber) => { + try { + await accountApi.deletePhoneNumber(phoneNumber); + } catch (e) { + console.log(`[Account] Error deleting phone number`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } + } + }; + + if (!config.enable_calls) { + return null; + } + return (
- {account?.phone_numbers.map(p => - navigator.clipboard.writeText(p.number)} - onDelete={() => handleDelete(p.number)} - /> + {account?.phone_numbers?.map(phoneNumber => + + {phoneNumber} + + } + variant="outlined" + onClick={() => handleCopy(phoneNumber)} + onDelete={() => handleDelete(phoneNumber)} + /> )} - handleAdd()}> + {!account?.phone_numbers && + {t("account_basics_phone_numbers_no_phone_numbers_yet")} + } +
+ + + setSnackOpen(false)} + message={t("account_basics_phone_numbers_copied_to_clipboard")} + /> +
) }; +const AddPhoneNumberDialog = (props) => { + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [code, setCode] = useState(""); + const [sending, setSending] = useState(false); + const [verificationCodeSent, setVerificationCodeSent] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + + const handleDialogSubmit = async () => { + if (!verificationCodeSent) { + await verifyPhone(); + } else { + await checkVerifyPhone(); + } + }; + + const handleCancel = () => { + if (verificationCodeSent) { + setVerificationCodeSent(false); + } else { + props.onClose(); + } + }; + + const verifyPhone = async () => { + try { + setSending(true); + await accountApi.verifyPhone(phoneNumber); + setVerificationCodeSent(true); + } catch (e) { + console.log(`[Account] Error sending verification`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } finally { + setSending(false); + } + }; + + const checkVerifyPhone = async () => { + try { + setSending(true); + await accountApi.checkVerifyPhone(phoneNumber, code); + props.onClose(); + } catch (e) { + console.log(`[Account] Error confirming verification`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } finally { + setSending(false); + } + }; + + return ( + + {t("account_basics_phone_numbers_dialog_title")} + + + {t("account_basics_phone_numbers_dialog_description")} + + {!verificationCodeSent && + setPhoneNumber(ev.target.value)} + fullWidth + inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }} + variant="standard" + /> + } + {verificationCodeSent && + setCode(ev.target.value)} + fullWidth + inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} + variant="standard" + /> + } + + + + + + + ); +}; + + const Stats = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); @@ -594,7 +740,7 @@ const TokensTable = (props) => { {token.token.slice(0, 12)} ... - + handleCopy(token.token)}> diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 4fd4f8c..95f1c47 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -288,7 +288,7 @@ const LoginPage = (props) => { /> - + diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js index c4d665e..0b91b1b 100644 --- a/web/src/components/UpgradeDialog.js +++ b/web/src/components/UpgradeDialog.js @@ -300,11 +300,9 @@ const TierCard = (props) => { {tier.limits.reservations > 0 && {t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}} {t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })} {t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })} - {tier.limits.sms > 0 && {t("account_upgrade_dialog_tier_features_sms", { sms: formatNumber(tier.limits.sms), count: tier.limits.sms })}} {tier.limits.calls > 0 && {t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}} {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })} {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} - {tier.limits.sms === 0 && {t("account_upgrade_dialog_tier_features_no_sms")}} {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}} {tier.prices && props.interval === SubscriptionInterval.MONTH && From 539ba43cd1e2a4975fddc0fbf6a9b10ef86419a0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 13 May 2023 12:26:14 -0400 Subject: [PATCH 10/97] WIP twilio --- cmd/serve.go | 3 --- server/config.go | 1 - server/server.go | 14 ++++++++++---- server/server_twilio.go | 10 ++++++++++ server/util.go | 8 ++++++++ server/visitor.go | 6 +++++- 6 files changed, 33 insertions(+), 9 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 9e02057..28081a0 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -84,7 +84,6 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), - altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-call-daily-limit", Aliases: []string{"visitor_call_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_CALL_DAILY_LIMIT"}, Value: server.DefaultVisitorCallDailyLimit, Usage: "max number of phone calls per visitor per day"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), @@ -171,7 +170,6 @@ func execServe(c *cli.Context) error { visitorMessageDailyLimit := c.Int("visitor-message-daily-limit") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish") - visitorCallDailyLimit := c.Int("visitor-call-daily-limit") behindProxy := c.Bool("behind-proxy") stripeSecretKey := c.String("stripe-secret-key") stripeWebhookKey := c.String("stripe-webhook-key") @@ -334,7 +332,6 @@ func execServe(c *cli.Context) error { conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish - conf.VisitorCallDailyLimit = visitorCallDailyLimit conf.VisitorSubscriberRateLimiting = visitorSubscriberRateLimiting conf.BehindProxy = behindProxy conf.StripeSecretKey = stripeSecretKey diff --git a/server/config.go b/server/config.go index 3fd88e8..80eb613 100644 --- a/server/config.go +++ b/server/config.go @@ -129,7 +129,6 @@ type Config struct { VisitorMessageDailyLimit int VisitorEmailLimitBurst int VisitorEmailLimitReplenish time.Duration - VisitorCallDailyLimit int VisitorAccountCreationLimitBurst int VisitorAccountCreationLimitReplenish time.Duration VisitorAuthFailureLimitBurst int diff --git a/server/server.go b/server/server.go index ce87f97..505de4b 100644 --- a/server/server.go +++ b/server/server.go @@ -691,12 +691,18 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e return nil, errHTTPTooManyRequestsLimitMessages.With(t) } else if email != "" && !vrate.EmailAllowed() { return nil, errHTTPTooManyRequestsLimitEmails.With(t) - } else if call != "" && !vrate.CallAllowed() { - return nil, errHTTPTooManyRequestsLimitCalls.With(t) + } else if call != "" { + call, err = s.convertPhoneNumber(v.User(), call) + if err != nil { + return nil, errHTTPBadRequestInvalidPhoneNumber.With(t) + } + if !vrate.CallAllowed() { + return nil, errHTTPTooManyRequestsLimitCalls.With(t) + } } // FIXME check allowed phone numbers - + if m.PollID != "" { m = newPollRequestMessage(t.ID, m.PollID) } @@ -893,7 +899,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi call = readParam(r, "x-call", "call") if call != "" && s.config.TwilioAccount == "" { return false, false, "", "", false, errHTTPBadRequestTwilioDisabled - } else if call != "" && !phoneNumberRegex.MatchString(call) { + } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid } messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") diff --git a/server/server_twilio.go b/server/server_twilio.go index a6b9109..128ae5e 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -31,6 +31,16 @@ const ( ` ) +func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, error) { + if u == nil { + return "", fmt.Errorf("user is nil") + } + if s.config.TwilioPhoneNumberConverter == nil { + return phoneNumber, nil + } + return s.config.TwilioPhoneNumberConverter(u, phoneNumber) +} + func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(v.User(), m))) data := url.Values{} diff --git a/server/util.go b/server/util.go index f0b49d2..a3a4554 100644 --- a/server/util.go +++ b/server/util.go @@ -18,6 +18,14 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool { if value == "" { return defaultValue } + return toBool(value) +} + +func isBoolValue(value string) bool { + return value == "1" || value == "yes" || value == "true" || value == "0" || value == "no" || value == "false" +} + +func toBool(value string) bool { return value == "1" || value == "yes" || value == "true" } diff --git a/server/visitor.go b/server/visitor.go index 4895c3f..e4c06f6 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -24,6 +24,10 @@ const ( // visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve. // This number is zero, and changing it may have unintended consequences in the web app, or otherwise visitorDefaultReservationsLimit = int64(0) + + // visitorDefaultCallsLimit is the amount of calls a user without a tier is allowed to make. + // This number is zero, because phone numbers have to be verified first. + visitorDefaultCallsLimit = int64(0) ) // Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter @@ -444,7 +448,7 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits { EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation! EmailLimitBurst: conf.VisitorEmailLimitBurst, EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish), - CallLimit: int64(conf.VisitorCallDailyLimit), + CallLimit: visitorDefaultCallsLimit, ReservationsLimit: visitorDefaultReservationsLimit, AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit, AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit, From 4fdbd42f508717907ef0eb6cf93b96f8f0c867a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20W=C3=BChr?= Date: Mon, 15 May 2023 12:14:23 +0200 Subject: [PATCH 11/97] Add woodpecker-ntfy plugin to integrations.md --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index 3eddb5e..d1a4d42 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -104,6 +104,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntfy_on_a_chip](https://github.com/gergepalfi/ntfy_on_a_chip) - ESP8266 and ESP32 client code to communicate with ntfy - [ntfy-sdk](https://github.com/yukibtc/ntfy-sdk) - ntfy client library to send notifications (Rust) - [ntfy_ynh](https://github.com/YunoHost-Apps/ntfy_ynh) - ntfy app for YunoHost +- [woodpecker-ntfy](https://codeberg.org/l-x/woodpecker-ntfy)- Woodpecker CI plugin for sending ntfy notfication from a pipeline (Go) - [drone-ntfy](https://github.com/Clortox/drone-ntfy) - Drone.io plugin for sending ntfy notifications from a pipeline (Shell) - [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java) - [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python) From e17cf676f4e6fcae1702811579960cf000cc1dc9 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 15 May 2023 10:58:37 -0400 Subject: [PATCH 12/97] Release notes --- docs/releases.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index e6f3949..38f7cf2 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1180,6 +1180,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ### ntfy server v2.5.0 (UNRELEASED) +**Features:** + +* Admin API to manage users and ACL, `v1/users` + `v1/users/access` ([#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket) + **Bug fixes + maintenance:** * Removed old ntfy website from ntfy entirely (no ticket) From 6e21bb742f2ba5b20bfe4f11cb7bb507e24a722d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 15 May 2023 11:03:19 -0400 Subject: [PATCH 13/97] Bump deps --- go.mod | 18 +- go.sum | 18 ++ web/package-lock.json | 371 +++++++++++++++++++++--------------------- 3 files changed, 216 insertions(+), 191 deletions(-) diff --git a/go.mod b/go.mod index e15e9b0..1f4c9e7 100644 --- a/go.mod +++ b/go.mod @@ -14,12 +14,12 @@ require ( github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8 github.com/stretchr/testify v1.8.1 github.com/urfave/cli/v2 v2.25.3 - golang.org/x/crypto v0.8.0 - golang.org/x/oauth2 v0.7.0 // indirect + golang.org/x/crypto v0.9.0 + golang.org/x/oauth2 v0.8.0 // indirect golang.org/x/sync v0.2.0 golang.org/x/term v0.8.0 golang.org/x/time v0.3.0 - google.golang.org/api v0.121.0 + google.golang.org/api v0.122.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -28,15 +28,15 @@ require github.com/pkg/errors v0.9.1 // indirect require ( firebase.google.com/go/v4 v4.11.0 github.com/prometheus/client_golang v1.15.1 - github.com/stripe/stripe-go/v74 v74.17.0 + github.com/stripe/stripe-go/v74 v74.18.0 ) require ( - cloud.google.com/go v0.110.1 // indirect - cloud.google.com/go/compute v1.19.1 // indirect + cloud.google.com/go v0.110.2 // indirect + cloud.google.com/go/compute v1.19.2 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect - cloud.google.com/go/iam v1.0.0 // indirect - cloud.google.com/go/longrunning v0.4.1 // indirect + cloud.google.com/go/iam v1.0.1 // indirect + cloud.google.com/go/longrunning v0.4.2 // indirect github.com/AlekSi/pointer v1.2.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -61,7 +61,7 @@ require ( github.com/stretchr/objx v0.5.0 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/net v0.9.0 // indirect + golang.org/x/net v0.10.0 // indirect golang.org/x/sys v0.8.0 // indirect golang.org/x/text v0.9.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect diff --git a/go.sum b/go.sum index 9ec101b..ccb1ae4 100644 --- a/go.sum +++ b/go.sum @@ -2,16 +2,24 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.110.1 h1:oDJ19Fu9TX9Xs06iyCw4yifSqZ7JQ8BeuVHcTmWQlOA= cloud.google.com/go v0.110.1/go.mod h1:uc+V/WjzxQ7vpkxfJhgW4Q4axWXyfAerpQOuSNDZyFw= +cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA= +cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY= cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE= +cloud.google.com/go/compute v1.19.2 h1:GbJtPo8OKVHbVep8jvM57KidbYHxeE68LOVqouNLrDY= +cloud.google.com/go/compute v1.19.2/go.mod h1:5f5a+iC1IriXYauaQ0EyQmEAEq9CGRnV5xJSQSlTV08= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA= cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE= cloud.google.com/go/iam v1.0.0 h1:hlQJMovyJJwYjZcTohUH4o1L8Z8kYz+E+W/zktiLCBc= cloud.google.com/go/iam v1.0.0/go.mod h1:ikbQ4f1r91wTmBmmOtBCOtuEOei6taatNXytzB7Cxew= +cloud.google.com/go/iam v1.0.1 h1:lyeCAU6jpnVNrE9zGQkTl3WgNgK/X+uWwaw0kynZJMU= +cloud.google.com/go/iam v1.0.1/go.mod h1:yR3tmSL8BcZB4bxByRv2jkSIahVmCtfKZwLYGBalRE8= cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM= cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo= +cloud.google.com/go/longrunning v0.4.2 h1:WDKiiNXFTaQ6qz/G8FCOkuY9kJmOJGY67wPUC1M2RbE= +cloud.google.com/go/longrunning v0.4.2/go.mod h1:OHrnaYyLUV6oqwh0xiS7e5sLQhP1m0QU9R+WhGDMgIQ= cloud.google.com/go/storage v1.30.1 h1:uOdMxAs8HExqBlnLtnQyP0YkvbiDpdGShGKtx6U/oNM= cloud.google.com/go/storage v1.30.1/go.mod h1:NfxhC0UJE1aXSx7CIIbCf7y9HKT7BiccwkR7+P7gN8E= firebase.google.com/go/v4 v4.11.0 h1:szjBoiF33A2FavRLIDZjW1mw+OsW/XAtHoYNIqWOjRk= @@ -141,6 +149,8 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stripe/stripe-go/v74 v74.17.0 h1:qVWSzmADr6gudznuAcPjB9ewzgxfyIhBCkyTbkxJcCw= github.com/stripe/stripe-go/v74 v74.17.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= +github.com/stripe/stripe-go/v74 v74.18.0 h1:ImSIoaVkTUozHxa21AhwHYBjwc8fVSJJJB1Q7oaXzIw= +github.com/stripe/stripe-go/v74 v74.18.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY= github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= @@ -155,6 +165,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= +golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -176,10 +188,14 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g= golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= +golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= +golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -227,6 +243,8 @@ golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3j golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= google.golang.org/api v0.121.0 h1:8Oopoo8Vavxx6gt+sgs8s8/X60WBAtKQq6JqnkF+xow= google.golang.org/api v0.121.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms= +google.golang.org/api v0.122.0 h1:zDobeejm3E7pEG1mNHvdxvjs5XJoCMzyNH+CmwL94Es= +google.golang.org/api v0.122.0/go.mod h1:gcitW0lvnyWjSp9nKxAbdHKIZ6vF4aajGueeslZOyms= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= diff --git a/web/package-lock.json b/web/package-lock.json index 5e1d9b3..f1b4785 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3134,9 +3134,9 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, "node_modules/@mui/base": { - "version": "5.0.0-alpha.128", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.128.tgz", - "integrity": "sha512-wub3wxNN+hUp8hzilMlXX3sZrPo75vsy1cXEQpqdTfIFlE9HprP1jlulFiPg5tfPst2OKmygXr2hhmgvAKRrzQ==", + "version": "5.0.0-beta.0", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.0.tgz", + "integrity": "sha512-ap+juKvt8R8n3cBqd/pGtZydQ4v2I/hgJKnvJRGjpSh3RvsvnDHO4rXov8MHQlH6VqpOekwgilFLGxMZjNTucA==", "dependencies": { "@babel/runtime": "^7.21.0", "@emotion/is-prop-valid": "^1.2.0", @@ -3166,9 +3166,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.12.3.tgz", - "integrity": "sha512-yiJZ+knaknPHuRKhRk4L6XiwppwkAahVal3LuYpvBH7GkA2g+D9WLEXOEnNYtVFUggyKf6fWGLGnx0iqzkU5YA==", + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.0.tgz", + "integrity": "sha512-5nXz2k8Rv2ZjtQY6kXirJVyn2+ODaQuAJmXSJtLDUQDKWp3PFUj6j3bILqR0JGOs9R5ejgwz3crLKsl6GwjwkQ==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui" @@ -3200,17 +3200,17 @@ } }, "node_modules/@mui/material": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.12.3.tgz", - "integrity": "sha512-xNmKlrEN4HsTaKFNLZfc7ie7CXx2YqEeO//hsXZx2p3MGtDdeMr2sV3jC4hsFs57RhQlF79weY7uVvC8xSuVbg==", + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.0.tgz", + "integrity": "sha512-ckS+9tCpAzpdJdaTF+btF0b6mF9wbXg/EVKtnoAWYi0UKXoXBAVvEUMNpLGA5xdpCdf+A6fPbVUEHs9TsfU+Yw==", "dependencies": { "@babel/runtime": "^7.21.0", - "@mui/base": "5.0.0-alpha.128", - "@mui/core-downloads-tracker": "^5.12.3", + "@mui/base": "5.0.0-beta.0", + "@mui/core-downloads-tracker": "^5.13.0", "@mui/system": "^5.12.3", "@mui/types": "^7.2.4", "@mui/utils": "^5.12.3", - "@types/react-transition-group": "^4.4.5", + "@types/react-transition-group": "^4.4.6", "clsx": "^1.2.1", "csstype": "^3.1.2", "prop-types": "^15.8.1", @@ -3948,9 +3948,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "4.17.34", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.34.tgz", - "integrity": "sha512-fvr49XlCGoUj2Pp730AItckfjat4WNb0lb3kfrLWffd+RLeoGAMsq7UOy04PAPtoL01uKwcp6u8nhzpgpDYr3w==", + "version": "4.17.35", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz", + "integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==", "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -4016,9 +4016,9 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" }, "node_modules/@types/node": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.0.tgz", - "integrity": "sha512-O+z53uwx64xY7D6roOi4+jApDGFg0qn6WHcxe5QeqjMaTezBO/mxdfFXIVAVVyNWKx84OmPB3L8kbVYOTeN34A==" + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz", + "integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q==" }, "node_modules/@types/parse-json": { "version": "4.0.0", @@ -4105,9 +4105,9 @@ "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==" }, "node_modules/@types/semver": { - "version": "7.3.13", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", - "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==" + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", + "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==" }, "node_modules/@types/send": { "version": "0.17.1", @@ -4175,14 +4175,14 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.2.tgz", - "integrity": "sha512-yVrXupeHjRxLDcPKL10sGQ/QlVrA8J5IYOEWVqk0lJaSZP7X5DfnP7Ns3cc74/blmbipQ1htFNVGsHX6wsYm0A==", + "version": "5.59.5", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.5.tgz", + "integrity": "sha512-feA9xbVRWJZor+AnLNAr7A8JRWeZqHUf4T9tlP+TN04b05pFVhO5eN7/O93Y/1OUlLMHKbnJisgDURs/qvtqdg==", "dependencies": { "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.59.2", - "@typescript-eslint/type-utils": "5.59.2", - "@typescript-eslint/utils": "5.59.2", + "@typescript-eslint/scope-manager": "5.59.5", + "@typescript-eslint/type-utils": "5.59.5", + "@typescript-eslint/utils": "5.59.5", "debug": "^4.3.4", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", @@ -4208,11 +4208,11 @@ } }, "node_modules/@typescript-eslint/experimental-utils": { - "version": "5.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.2.tgz", - "integrity": "sha512-JLw2UImsjHDuVukpA8Nt+UK7JKE/LQAeV3tU5f7wJo2/NNYVwcakzkWjoYzu/2qzWY/Z9c7zojngNDfecNt92g==", + "version": "5.59.5", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.5.tgz", + "integrity": "sha512-ArcSSBifznsKNA/p4h2w3Olt/T8AZf3bNglxD8OnuTsSDJbRpjPPmI8qpr6ijyvk1J/T3GMJHwRIluS/Kuz9kA==", "dependencies": { - "@typescript-eslint/utils": "5.59.2" + "@typescript-eslint/utils": "5.59.5" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -4226,13 +4226,13 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.2.tgz", - "integrity": "sha512-uq0sKyw6ao1iFOZZGk9F8Nro/8+gfB5ezl1cA06SrqbgJAt0SRoFhb9pXaHvkrxUpZaoLxt8KlovHNk8Gp6/HQ==", + "version": "5.59.5", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.5.tgz", + "integrity": "sha512-NJXQC4MRnF9N9yWqQE2/KLRSOLvrrlZb48NGVfBa+RuPMN6B7ZcK5jZOvhuygv4D64fRKnZI4L4p8+M+rfeQuw==", "dependencies": { - "@typescript-eslint/scope-manager": "5.59.2", - "@typescript-eslint/types": "5.59.2", - "@typescript-eslint/typescript-estree": "5.59.2", + "@typescript-eslint/scope-manager": "5.59.5", + "@typescript-eslint/types": "5.59.5", + "@typescript-eslint/typescript-estree": "5.59.5", "debug": "^4.3.4" }, "engines": { @@ -4252,12 +4252,12 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.2.tgz", - "integrity": "sha512-dB1v7ROySwQWKqQ8rEWcdbTsFjh2G0vn8KUyvTXdPoyzSL6lLGkiXEV5CvpJsEe9xIdKV+8Zqb7wif2issoOFA==", + "version": "5.59.5", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.5.tgz", + "integrity": "sha512-jVecWwnkX6ZgutF+DovbBJirZcAxgxC0EOHYt/niMROf8p4PwxxG32Qdhj/iIQQIuOflLjNkxoXyArkcIP7C3A==", "dependencies": { - "@typescript-eslint/types": "5.59.2", - "@typescript-eslint/visitor-keys": "5.59.2" + "@typescript-eslint/types": "5.59.5", + "@typescript-eslint/visitor-keys": "5.59.5" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -4268,12 +4268,12 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.2.tgz", - "integrity": "sha512-b1LS2phBOsEy/T381bxkkywfQXkV1dWda/z0PhnIy3bC5+rQWQDS7fk9CSpcXBccPY27Z6vBEuaPBCKCgYezyQ==", + "version": "5.59.5", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.5.tgz", + "integrity": "sha512-4eyhS7oGym67/pSxA2mmNq7X164oqDYNnZCUayBwJZIRVvKpBCMBzFnFxjeoDeShjtO6RQBHBuwybuX3POnDqg==", "dependencies": { - "@typescript-eslint/typescript-estree": "5.59.2", - "@typescript-eslint/utils": "5.59.2", + "@typescript-eslint/typescript-estree": "5.59.5", + "@typescript-eslint/utils": "5.59.5", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -4294,9 +4294,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.2.tgz", - "integrity": "sha512-LbJ/HqoVs2XTGq5shkiKaNTuVv5tTejdHgfdjqRUGdYhjW1crm/M7og2jhVskMt8/4wS3T1+PfFvL1K3wqYj4w==", + "version": "5.59.5", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.5.tgz", + "integrity": "sha512-xkfRPHbqSH4Ggx4eHRIO/eGL8XL4Ysb4woL8c87YuAo8Md7AUjyWKa9YMwTL519SyDPrfEgKdewjkxNCVeJW7w==", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -4306,12 +4306,12 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.2.tgz", - "integrity": "sha512-+j4SmbwVmZsQ9jEyBMgpuBD0rKwi9RxRpjX71Brr73RsYnEr3Lt5QZ624Bxphp8HUkSKfqGnPJp1kA5nl0Sh7Q==", + "version": "5.59.5", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.5.tgz", + "integrity": "sha512-+XXdLN2CZLZcD/mO7mQtJMvCkzRfmODbeSKuMY/yXbGkzvA9rJyDY5qDYNoiz2kP/dmyAxXquL2BvLQLJFPQIg==", "dependencies": { - "@typescript-eslint/types": "5.59.2", - "@typescript-eslint/visitor-keys": "5.59.2", + "@typescript-eslint/types": "5.59.5", + "@typescript-eslint/visitor-keys": "5.59.5", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -4332,16 +4332,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.2.tgz", - "integrity": "sha512-kSuF6/77TZzyGPhGO4uVp+f0SBoYxCDf+lW3GKhtKru/L8k/Hd7NFQxyWUeY7Z/KGB2C6Fe3yf2vVi4V9TsCSQ==", + "version": "5.59.5", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.5.tgz", + "integrity": "sha512-sCEHOiw+RbyTii9c3/qN74hYDPNORb8yWCoPLmB7BIflhplJ65u2PBpdRla12e3SSTJ2erRkPjz7ngLHhUegxA==", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.59.2", - "@typescript-eslint/types": "5.59.2", - "@typescript-eslint/typescript-estree": "5.59.2", + "@typescript-eslint/scope-manager": "5.59.5", + "@typescript-eslint/types": "5.59.5", + "@typescript-eslint/typescript-estree": "5.59.5", "eslint-scope": "^5.1.1", "semver": "^7.3.7" }, @@ -4377,11 +4377,11 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.59.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.2.tgz", - "integrity": "sha512-EEpsO8m3RASrKAHI9jpavNv9NlEUebV4qmF1OWxSTtKSFBpC1NCmWazDQHFivRf0O1DV11BA645yrLEVQ0/Lig==", + "version": "5.59.5", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.5.tgz", + "integrity": "sha512-qL+Oz+dbeBRTeyJTIy0eniD3uvqU7x+y1QceBismZ41hd4aBSRh8UAw4pZP0+XzLuPZmx4raNMq/I+59W2lXKA==", "dependencies": { - "@typescript-eslint/types": "5.59.2", + "@typescript-eslint/types": "5.59.5", "eslint-visitor-keys": "^3.3.0" }, "engines": { @@ -4393,133 +4393,133 @@ } }, "node_modules/@webassemblyjs/ast": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.5.tgz", - "integrity": "sha512-LHY/GSAZZRpsNQH+/oHqhRQ5FT7eoULcBqgfyTB5nQHogFnK3/7QoN7dLnwSE/JkUAF0SrRuclT7ODqMFtWxxQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", + "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.5", - "@webassemblyjs/helper-wasm-bytecode": "1.11.5" + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" } }, "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.5.tgz", - "integrity": "sha512-1j1zTIC5EZOtCplMBG/IEwLtUojtwFVwdyVMbL/hwWqbzlQoJsWCOavrdnLkemwNoC/EOwtUFch3fuo+cbcXYQ==" + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==" }, "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.5.tgz", - "integrity": "sha512-L65bDPmfpY0+yFrsgz8b6LhXmbbs38OnwDCf6NpnMUYqa+ENfE5Dq9E42ny0qz/PdR0LJyq/T5YijPnU8AXEpA==" + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.5.tgz", - "integrity": "sha512-fDKo1gstwFFSfacIeH5KfwzjykIE6ldh1iH9Y/8YkAZrhmu4TctqYjSh7t0K2VyDSXOZJ1MLhht/k9IvYGcIxg==" + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==" }, "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.5.tgz", - "integrity": "sha512-DhykHXM0ZABqfIGYNv93A5KKDw/+ywBFnuWybZZWcuzWHfbp21wUfRkbtz7dMGwGgT4iXjWuhRMA2Mzod6W4WA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.5", - "@webassemblyjs/helper-api-error": "1.11.5", + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.5.tgz", - "integrity": "sha512-oC4Qa0bNcqnjAowFn7MPCETQgDYytpsfvz4ujZz63Zu/a/v71HeCAAmZsgZ3YVKec3zSPYytG3/PrRCqbtcAvA==" + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.5.tgz", - "integrity": "sha512-uEoThA1LN2NA+K3B9wDo3yKlBfVtC6rh0i4/6hvbz071E8gTNZD/pT0MsBf7MeD6KbApMSkaAK0XeKyOZC7CIA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", + "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", "dependencies": { - "@webassemblyjs/ast": "1.11.5", - "@webassemblyjs/helper-buffer": "1.11.5", - "@webassemblyjs/helper-wasm-bytecode": "1.11.5", - "@webassemblyjs/wasm-gen": "1.11.5" + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6" } }, "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.5.tgz", - "integrity": "sha512-37aGq6qVL8A8oPbPrSGMBcp38YZFXcHfiROflJn9jxSdSMMM5dS5P/9e2/TpaJuhE+wFrbukN2WI6Hw9MH5acg==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "node_modules/@webassemblyjs/leb128": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.5.tgz", - "integrity": "sha512-ajqrRSXaTJoPW+xmkfYN6l8VIeNnR4vBOTQO9HzR7IygoCcKWkICbKFbVTNMjMgMREqXEr0+2M6zukzM47ZUfQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", "dependencies": { "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/utf8": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.5.tgz", - "integrity": "sha512-WiOhulHKTZU5UPlRl53gHR8OxdGsSOxqfpqWeA2FmcwBMaoEdz6b2x2si3IwC9/fSPLfe8pBMRTHVMk5nlwnFQ==" + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.5.tgz", - "integrity": "sha512-C0p9D2fAu3Twwqvygvf42iGCQ4av8MFBLiTb+08SZ4cEdwzWx9QeAHDo1E2k+9s/0w1DM40oflJOpkZ8jW4HCQ==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", + "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", "dependencies": { - "@webassemblyjs/ast": "1.11.5", - "@webassemblyjs/helper-buffer": "1.11.5", - "@webassemblyjs/helper-wasm-bytecode": "1.11.5", - "@webassemblyjs/helper-wasm-section": "1.11.5", - "@webassemblyjs/wasm-gen": "1.11.5", - "@webassemblyjs/wasm-opt": "1.11.5", - "@webassemblyjs/wasm-parser": "1.11.5", - "@webassemblyjs/wast-printer": "1.11.5" + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-opt": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6", + "@webassemblyjs/wast-printer": "1.11.6" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.5.tgz", - "integrity": "sha512-14vteRlRjxLK9eSyYFvw1K8Vv+iPdZU0Aebk3j6oB8TQiQYuO6hj9s4d7qf6f2HJr2khzvNldAFG13CgdkAIfA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", + "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", "dependencies": { - "@webassemblyjs/ast": "1.11.5", - "@webassemblyjs/helper-wasm-bytecode": "1.11.5", - "@webassemblyjs/ieee754": "1.11.5", - "@webassemblyjs/leb128": "1.11.5", - "@webassemblyjs/utf8": "1.11.5" + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.5.tgz", - "integrity": "sha512-tcKwlIXstBQgbKy1MlbDMlXaxpucn42eb17H29rawYLxm5+MsEmgPzeCP8B1Cl69hCice8LeKgZpRUAPtqYPgw==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", + "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", "dependencies": { - "@webassemblyjs/ast": "1.11.5", - "@webassemblyjs/helper-buffer": "1.11.5", - "@webassemblyjs/wasm-gen": "1.11.5", - "@webassemblyjs/wasm-parser": "1.11.5" + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.5.tgz", - "integrity": "sha512-SVXUIwsLQlc8srSD7jejsfTU83g7pIGr2YYNb9oHdtldSxaOhvA5xwvIiWIfcX8PlSakgqMXsLpLfbbJ4cBYew==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", + "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", "dependencies": { - "@webassemblyjs/ast": "1.11.5", - "@webassemblyjs/helper-api-error": "1.11.5", - "@webassemblyjs/helper-wasm-bytecode": "1.11.5", - "@webassemblyjs/ieee754": "1.11.5", - "@webassemblyjs/leb128": "1.11.5", - "@webassemblyjs/utf8": "1.11.5" + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.5.tgz", - "integrity": "sha512-f7Pq3wvg3GSPUPzR0F6bmI89Hdb+u9WXrSKc4v+N0aV0q6r42WoF92Jp2jEorBEBRoRNXgjp53nBniDXcqZYPA==", + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", + "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", "dependencies": { - "@webassemblyjs/ast": "1.11.5", + "@webassemblyjs/ast": "1.11.6", "@xtuc/long": "4.2.2" } }, @@ -4582,9 +4582,9 @@ } }, "node_modules/acorn-import-assertions": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", - "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", "peerDependencies": { "acorn": "^8" } @@ -5511,9 +5511,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001486", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001486.tgz", - "integrity": "sha512-uv7/gXuHi10Whlj0pp5q/tsK/32J2QSqVRKQhs2j8VsDCjgyruAh/eEXHF822VqO9yT6iZKw3nRwZRSPBE9OQg==", + "version": "1.0.30001487", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001487.tgz", + "integrity": "sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==", "funding": [ { "type": "opencollective", @@ -6171,13 +6171,19 @@ } }, "node_modules/cssdb": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.5.4.tgz", - "integrity": "sha512-fGD+J6Jlq+aurfE1VDXlLS4Pt0VtNlu2+YgfGOdMxRyl/HQ9bDiHTwSck1Yz8A97Dt/82izSK6Bp/4nVqacOsg==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.6.0.tgz", + "integrity": "sha512-Nna7rph8V0jC6+JBY4Vk4ndErUmfJfV6NJCaZdurL0omggabiy+QB2HCQtu5c/ACLZ0I7REv7A4QyPIoYzZx0w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + } + ] }, "node_modules/cssesc": { "version": "3.0.0", @@ -6743,9 +6749,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.385", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.385.tgz", - "integrity": "sha512-L9zlje9bIw0h+CwPQumiuVlfMcV4boxRjFIWDcLfFqTZNbkwOExBzfmswytHawObQX4OUhtNv8gIiB21kOurIg==" + "version": "1.4.394", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.394.tgz", + "integrity": "sha512-0IbC2cfr8w5LxTz+nmn2cJTGafsK9iauV2r5A5scfzyovqLrxuLoxOHE5OBobP3oVIggJT+0JfKnw9sm87c8Hw==" }, "node_modules/emittery": { "version": "0.8.1", @@ -6780,9 +6786,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.13.0.tgz", - "integrity": "sha512-eyV8f0y1+bzyfh8xAwW/WTSZpLbjhqc4ne9eGSH4Zo2ejdyiNG9pU6mf9DG8a7+Auk6MFTlNOT4Y2y/9k8GKVg==", + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.14.0.tgz", + "integrity": "sha512-+DCows0XNwLDcUhbFJPdlQEVnT2zXlCv7hPxemTz86/O+B/hCQ+mb7ydkPKiflpVraqLPCAfu7lDy+hBXueojw==", "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -7361,9 +7367,9 @@ } }, "node_modules/eslint-plugin-testing-library": { - "version": "5.10.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.10.3.tgz", - "integrity": "sha512-0yhsKFsjHLud5PM+f2dWr9K3rqYzMy4cSHs3lcmFYMa1CdSzRvHGgXvsFarBjZ41gU8jhTdMIkg8jHLxGJqLqw==", + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.11.0.tgz", + "integrity": "sha512-ELY7Gefo+61OfXKlQeXNIDVVLPcvKTeiQOoMZG9TeuWa7Ln4dUNRv8JdRWBQI9Mbb427XGlVB1aa1QPZxBJM8Q==", "dependencies": { "@typescript-eslint/utils": "^5.58.0" }, @@ -8342,12 +8348,13 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", - "integrity": "sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", "dependencies": { "function-bind": "^1.1.1", "has": "^1.0.3", + "has-proto": "^1.0.1", "has-symbols": "^1.0.3" }, "funding": { @@ -9551,14 +9558,14 @@ } }, "node_modules/jake": { - "version": "10.8.5", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", - "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==", + "version": "10.8.6", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.6.tgz", + "integrity": "sha512-G43Ub9IYEFfu72sua6rzooi8V8Gz2lkfk48rW20vEWCGizeaEPlKB1Kh8JIA84yQbiAEfqlPmSpGgCKKxH3rDA==", "dependencies": { "async": "^3.2.3", "chalk": "^4.0.2", - "filelist": "^1.0.1", - "minimatch": "^3.0.4" + "filelist": "^1.0.4", + "minimatch": "^3.1.2" }, "bin": { "jake": "bin/cli.js" @@ -15068,9 +15075,9 @@ } }, "node_modules/semver": { - "version": "7.5.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.0.tgz", - "integrity": "sha512-+XC0AD/R7Q2mPSRuy2Id0+CGTZ98+8f+KvwirxOKIEyid+XSx6HbC63p+O4IndTHuX5Z+JxQ0TghCkO5Cg/2HA==", + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", + "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -15969,9 +15976,9 @@ } }, "node_modules/terser": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.1.tgz", - "integrity": "sha512-hVl35zClmpisy6oaoKALOpS0rDYLxRFLHhRuDlEGTKey9qHjS1w9GMORjuwIMt70Wan4lwsLYyWDVnWgF+KUEw==", + "version": "5.17.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz", + "integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==", "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", @@ -16548,9 +16555,9 @@ } }, "node_modules/webpack": { - "version": "5.82.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.82.0.tgz", - "integrity": "sha512-iGNA2fHhnDcV1bONdUu554eZx+XeldsaeQ8T67H6KKHl2nUSwX8Zm7cmzOA46ox/X1ARxf7Bjv8wQ/HsB5fxBg==", + "version": "5.82.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.82.1.tgz", + "integrity": "sha512-C6uiGQJ+Gt4RyHXXYt+v9f+SN1v83x68URwgxNQ98cvH8kxiuywWGP4XeNZ1paOzZ63aY3cTciCEQJNFUljlLw==", "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", @@ -16561,7 +16568,7 @@ "acorn-import-assertions": "^1.7.6", "browserslist": "^4.14.5", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.13.0", + "enhanced-resolve": "^5.14.0", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", From ed0c1abd2f4cc1c3b0cce2475dff20855301d99e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 15 May 2023 13:37:30 -0400 Subject: [PATCH 14/97] Tiny web app fixes --- web/src/app/Api.js | 7 +++++-- web/src/components/Preferences.js | 4 ++-- web/src/components/ReserveDialogs.js | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/web/src/app/Api.js b/web/src/app/Api.js index 3d20d92..59bd78b 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -21,8 +21,11 @@ class Api { const headers = maybeWithAuth({}, user); console.log(`[Api] Polling ${url}`); for await (let line of fetchLinesIterator(url, headers)) { - console.log(`[Api, ${shortUrl}] Received message ${line}`); - messages.push(JSON.parse(line)); + const message = JSON.parse(line); + if (message.id) { + console.log(`[Api, ${shortUrl}] Received message ${line}`); + messages.push(message); + } } return messages; } diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index 0cccc6b..ec1c79d 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -541,8 +541,8 @@ const ReservationsTable = (props) => { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { subscriptions } = useOutletContext(); const localSubscriptions = (subscriptions?.length > 0) - ? Object.assign(...subscriptions.filter(s => s.baseUrl === config.base_url).map(s => ({[s.topic]: s}))) - : []; + ? Object.assign({}, ...subscriptions.filter(s => s.baseUrl === config.base_url).map(s => ({[s.topic]: s}))) + : {}; const handleEditClick = (reservation) => { setDialogKey(prev => prev+1); diff --git a/web/src/components/ReserveDialogs.js b/web/src/components/ReserveDialogs.js index 7a6a044..e466269 100644 --- a/web/src/components/ReserveDialogs.js +++ b/web/src/components/ReserveDialogs.js @@ -34,7 +34,7 @@ export const ReserveAddDialog = (props) => { const handleSubmit = async () => { try { await accountApi.upsertReservation(topic, everyone); - console.debug(`[ReserveAddDialog] Added reservation for topic ${t}: ${everyone}`); + console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`); } catch (e) { console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); if (e instanceof UnauthorizedError) { From f998d4d2adc4f582035eb37cf0537eb2d0b1e272 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 15 May 2023 19:49:34 -0400 Subject: [PATCH 15/97] Fix web app i18n issue in account preferences --- docs/releases.md | 3 ++- web/src/components/Account.js | 2 +- web/src/components/Preferences.js | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 38f7cf2..440b950 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1190,7 +1190,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Make emoji lookup for emails more efficient ([#725](https://github.com/binwiederhier/ntfy/pull/725), thanks to [@adamantike](https://github.com/adamantike)) * Fix potential subscriber ID clash ([#712](https://github.com/binwiederhier/ntfy/issues/712), thanks to [@peterbourgon](https://github.com/peterbourgon) for reporting, and [@dropdevrahul](https://github.com/dropdevrahul) for fixing) * Support for `quoted-printable` in incoming emails ([#719](https://github.com/binwiederhier/ntfy/pull/719), thanks to [@Aerion](https://github.com/Aerion)) -* Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing) +* Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [@un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing) +* Fix web app i18n issue in account preferences ([#730](https://github.com/binwiederhier/ntfy/issues/730), thanks to [@codebude](https://github.com/codebude) for reporting) ### ntfy Android app v1.16.1 (UNRELEASED) diff --git a/web/src/components/Account.js b/web/src/components/Account.js index e5b6007..0e37aa3 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -390,7 +390,7 @@ const Stats = () => { description={t("account_usage_attachment_storage_description", { filesize: formatBytes(account.limits.attachment_file_size), expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, { - language: i18n.language, + language: i18n.resolvedLanguage, fallbacks: ["en"] }) })} diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index ec1c79d..fc8cb35 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -436,7 +436,7 @@ const Appearance = () => { const Language = () => { const { t, i18n } = useTranslation(); const labelId = "prefLanguage"; - const lang = i18n.language ?? "en"; + const lang = i18n.resolvedLanguage ?? "en"; // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts. // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows. From 4b9e0c5c3817ca4ed716dab6f12f473e168eaa15 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 15 May 2023 20:42:43 -0400 Subject: [PATCH 16/97] Phone number verification in publishing --- server/errors.go | 4 +++- server/server.go | 16 +++++++--------- server/server_twilio.go | 21 +++++++++++++++++---- web/src/components/Account.js | 8 ++++++++ 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/server/errors.go b/server/errors.go index ee5223b..a42641b 100644 --- a/server/errors.go +++ b/server/errors.go @@ -108,8 +108,10 @@ var ( errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil} errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil} errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil} - errHTTPBadRequestTwilioDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: Calling is disabled", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/publish/#phone-calls", nil} errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/server.go b/server/server.go index 430fa5c..08cf08d 100644 --- a/server/server.go +++ b/server/server.go @@ -707,17 +707,14 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e } else if email != "" && !vrate.EmailAllowed() { return nil, errHTTPTooManyRequestsLimitEmails.With(t) } else if call != "" { - call, err = s.convertPhoneNumber(v.User(), call) - if err != nil { - return nil, errHTTPBadRequestInvalidPhoneNumber.With(t) - } - if !vrate.CallAllowed() { + var httpErr *errHTTP + call, httpErr = s.convertPhoneNumber(v.User(), call) + if httpErr != nil { + return nil, httpErr.With(t) + } else if !vrate.CallAllowed() { return nil, errHTTPTooManyRequestsLimitCalls.With(t) } } - - // FIXME check allowed phone numbers - if m.PollID != "" { m = newPollRequestMessage(t.ID, m.PollID) } @@ -741,6 +738,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e "message_firebase": firebase, "message_unifiedpush": unifiedpush, "message_email": email, + "message_call": call, }) if ev.IsTrace() { ev.Field("message_body", util.MaybeMarshalJSON(m)).Trace("Received message") @@ -913,7 +911,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } call = readParam(r, "x-call", "call") if call != "" && s.config.TwilioAccount == "" { - return false, false, "", "", false, errHTTPBadRequestTwilioDisabled + return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid } diff --git a/server/server_twilio.go b/server/server_twilio.go index 128ae5e..2c3d0a3 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -31,14 +31,27 @@ const ( ` ) -func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, error) { +func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) { if u == nil { - return "", fmt.Errorf("user is nil") + return "", errHTTPBadRequestAnonymousCallsNotAllowed } - if s.config.TwilioPhoneNumberConverter == nil { + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + if err != nil { + return "", errHTTPInternalError + } else if len(phoneNumbers) == 0 { + return "", errHTTPBadRequestPhoneNumberNotVerified + } + if toBool(phoneNumber) { + return phoneNumbers[0], nil + } else if util.Contains(phoneNumbers, phoneNumber) { return phoneNumber, nil } - return s.config.TwilioPhoneNumberConverter(u, phoneNumber) + for _, p := range phoneNumbers { + if p == phoneNumber { + return phoneNumber, nil + } + } + return "", errHTTPBadRequestPhoneNumberNotVerified } func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 706ac02..28d24a3 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -359,6 +359,14 @@ const PhoneNumbers = () => { return null; } + if (account?.limits.calls === 0) { + return ( + {t("account_basics_phone_numbers_title")}{config.enable_payments && }} description={t("account_basics_phone_numbers_description")}> + {t("account_usage_calls_none")} + + ) + } + return (
From deb4f2485690ac7ca3dad5c4f8995cccfc411788 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 15 May 2023 22:06:43 -0400 Subject: [PATCH 17/97] Cont'd, getting there --- docs/publish.md | 57 ++++++++++++++++++---------------------- server/server_metrics.go | 10 ------- server/server_twilio.go | 39 +++++++++++++-------------- 3 files changed, 44 insertions(+), 62 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index ef4c9a8..2491671 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2698,19 +2698,25 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt ## Phone calls _Supported on:_ :material-android: :material-apple: :material-firefox: -You can use ntfy to call a phone and **read the message out loud using text-to-speech**, by specifying a phone number a header. +You can use ntfy to call a phone and **read the message out loud using text-to-speech**. Similar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have the ntfy app installed on their phone. -Phone numbers have to be previously verified (via the web app). To forward a message as a voice call, pass a phone number -in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`. You may -also simply pass `yes` as a value if you only have one verified phone number. +**Phone numbers have to be previously verified** (via the web app), so this feature is **only available to authenticated users**. +To forward a message as a voice call, pass a phone number in the `X-Call` header (or its alias: `Call`), prefixed with a +plus sign and the country code, e.g. `+12223334444`. You may also simply pass `yes` as a value to pick the first of your +verified phone numbers. + +!!! info + As of today, the text-to-speed voice used will only support English. If there is demand for other languages, we'll + be happy to add support for that. Please [open an issue on GitHub](https://github.com/binwiederhier/ntfy/issues). On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans. === "Command line (curl)" ``` curl \ + -u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ -H "Call: +12223334444" \ -d "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help." \ ntfy.sh/alerts @@ -2719,6 +2725,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl === "ntfy CLI" ``` ntfy publish \ + --token=tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ --call=+12223334444 \ alerts "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help." ``` @@ -2727,6 +2734,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl ``` http POST /alerts HTTP/1.1 Host: ntfy.sh + Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 Call: +12223334444 Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help. @@ -2738,9 +2746,8 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl method: 'POST', body: "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.", headers: { - 'Email': 'phil@example.com', - 'Tags': 'warning,skull,backup-host,ssh-login', - 'Priority': 'high' + 'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2', + 'Call': '+12223334444' } }) ``` @@ -2748,10 +2755,9 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl === "Go" ``` go req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts", - strings.NewReader("Unknown login from 5.31.23.83 to backups.example.com")) - req.Header.Set("Email", "phil@example.com") - req.Header.Set("Tags", "warning,skull,backup-host,ssh-login") - req.Header.Set("Priority", "high") + strings.NewReader("Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.")) + req.Header.Set("Call", "+12223334444") + req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2") http.DefaultClient.Do(req) ``` @@ -2761,12 +2767,10 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl Method = "POST" URI = "https://ntfy.sh/alerts" Headers = @{ - Title = "Low disk space alert" - Priority = "high" - Tags = "warning,skull,backup-host,ssh-login") - Email = "phil@example.com" + Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" + Call = "+12223334444" } - Body = "Unknown login from 5.31.23.83 to backups.example.com" + Body = "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help." } Invoke-RestMethod @Request ``` @@ -2774,11 +2778,10 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl === "Python" ``` python requests.post("https://ntfy.sh/alerts", - data="Unknown login from 5.31.23.83 to backups.example.com", + data="Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.", headers={ - "Email": "phil@example.com", - "Tags": "warning,skull,backup-host,ssh-login", - "Priority": "high" + "Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", + "Call": "+12223334444" }) ``` @@ -2789,21 +2792,13 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl 'method' => 'POST', 'header' => "Content-Type: text/plain\r\n" . - "Email: phil@example.com\r\n" . - "Tags: warning,skull,backup-host,ssh-login\r\n" . - "Priority: high", - 'content' => 'Unknown login from 5.31.23.83 to backups.example.com' + "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\r\n" . + "Call: +12223334444", + 'content' => 'Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.' ] ])); ``` -Here's what that looks like in Google Mail: - -
- ![e-mail notification](static/img/screenshot-email.png){ width=600 } -
E-mail notification
-
- ## Authentication Depending on whether the server is configured to support [access control](config.md#access-control), some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. diff --git a/server/server_metrics.go b/server/server_metrics.go index d2e6f1c..88fa9f1 100644 --- a/server/server_metrics.go +++ b/server/server_metrics.go @@ -15,8 +15,6 @@ var ( metricEmailsPublishedFailure prometheus.Counter metricEmailsReceivedSuccess prometheus.Counter metricEmailsReceivedFailure prometheus.Counter - metricSMSSentSuccess prometheus.Counter - metricSMSSentFailure prometheus.Counter metricCallsMadeSuccess prometheus.Counter metricCallsMadeFailure prometheus.Counter metricUnifiedPushPublishedSuccess prometheus.Counter @@ -61,12 +59,6 @@ func initMetrics() { metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{ Name: "ntfy_emails_received_failure", }) - metricSMSSentSuccess = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_sms_sent_success", - }) - metricSMSSentFailure = prometheus.NewCounter(prometheus.CounterOpts{ - Name: "ntfy_sms_sent_failure", - }) metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{ Name: "ntfy_calls_made_success", }) @@ -111,8 +103,6 @@ func initMetrics() { metricEmailsPublishedFailure, metricEmailsReceivedSuccess, metricEmailsReceivedFailure, - metricSMSSentSuccess, - metricSMSSentFailure, metricCallsMadeSuccess, metricCallsMadeFailure, metricUnifiedPushPublishedSuccess, diff --git a/server/server_twilio.go b/server/server_twilio.go index 2c3d0a3..e543813 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -15,19 +15,21 @@ import ( ) const ( - twilioMessageFooterFormat = "This message was sent by %s via %s" - twilioCallEndpoint = "Calls.json" - twilioCallFormat = ` + twilioCallEndpoint = "Calls.json" + twilioCallFormat = ` - You have a message from notify on topic %s. Message: - - %s - - End message. - - %s - + + You have a notification from notify on topic %s. Message: + + %s + + End message. + + This message was sent by user %s. It will be repeated up to five times. + + + Goodbye. ` ) @@ -55,7 +57,11 @@ func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, * } func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { - body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(s.messageFooter(v.User(), m))) + u, sender := v.User(), m.Sender.String() + if u != nil { + sender = u.Name + } + body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender)) data := url.Values{} data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) @@ -186,15 +192,6 @@ func (s *Server) performTwilioMessagingRequestInternal(endpoint string, data url return string(response), nil } -func (s *Server) messageFooter(u *user.User, m *message) string { // u may be nil! - topicURL := s.config.BaseURL + "/" + m.Topic - sender := m.Sender.String() - if u != nil { - sender = fmt.Sprintf("%s (%s)", u.Name, m.Sender) - } - return fmt.Sprintf(twilioMessageFooterFormat, sender, util.ShortTopicURL(topicURL)) -} - func xmlEscapeText(text string) string { var buf bytes.Buffer _ = xml.EscapeText(&buf, []byte(text)) From 7c574d73de5a86a88ccca555398349cb58699244 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 May 2023 14:15:58 -0400 Subject: [PATCH 18/97] Cont'd Twilio stuff --- cmd/serve.go | 6 +- cmd/tier.go | 2 +- log/event.go | 41 ++--- server/config.go | 5 +- server/server.go | 11 +- server/server.yml | 12 +- server/server_account.go | 8 +- server/server_middleware.go | 9 ++ server/server_twilio.go | 107 +++++-------- server/server_twilio_test.go | 226 +++++++++++++++++----------- server/types.go | 3 - web/public/static/langs/en.json | 2 +- web/src/app/AccountApi.js | 12 +- web/src/app/utils.js | 1 + web/src/components/Account.js | 4 +- web/src/components/PublishDialog.js | 27 ---- 16 files changed, 240 insertions(+), 236 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 28081a0..4e123e9 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -73,7 +73,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for phone calls, e.g. AC123..."}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-from-number", Aliases: []string{"twilio_from_number"}, EnvVars: []string{"NTFY_TWILIO_FROM_NUMBER"}, Usage: "Twilio number to use for outgoing calls and text messages"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-from-number", Aliases: []string{"twilio_from_number"}, EnvVars: []string{"NTFY_TWILIO_FROM_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), @@ -217,8 +217,8 @@ func execServe(c *cli.Context) error { return errors.New("cannot set enable-signup without also setting enable-login") } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") - } else if twilioAccount != "" && (twilioAuthToken == "" || twilioFromNumber == "" || baseURL == "") { - return errors.New("if stripe-account is set, twilio-auth-token, twilio-from-number and base-url must also be set") + } else if twilioAccount != "" && (twilioAuthToken == "" || twilioFromNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") { + return errors.New("if twilio-account is set, twilio-auth-token, twilio-from-number, twilio-verify-service, base-url, and auth-file must also be set") } // Backwards compatibility diff --git a/cmd/tier.go b/cmd/tier.go index e77927c..f1c8ddc 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -18,7 +18,7 @@ const ( defaultMessageLimit = 5000 defaultMessageExpiryDuration = "12h" defaultEmailLimit = 20 - defaultCallLimit = 10 + defaultCallLimit = 0 defaultReservationLimit = 3 defaultAttachmentFileSizeLimit = "15M" defaultAttachmentTotalSizeLimit = "100M" diff --git a/log/event.go b/log/event.go index ccde412..b4b8f59 100644 --- a/log/event.go +++ b/log/event.go @@ -41,34 +41,34 @@ func newEvent() *Event { // Fatal logs the event as FATAL, and exits the program with exit code 1 func (e *Event) Fatal(message string, v ...any) { - e.Field(fieldExitCode, 1).maybeLog(FatalLevel, message, v...) + e.Field(fieldExitCode, 1).Log(FatalLevel, message, v...) fmt.Fprintf(os.Stderr, message+"\n", v...) // Always output error to stderr os.Exit(1) } // Error logs the event with log level error -func (e *Event) Error(message string, v ...any) { - e.maybeLog(ErrorLevel, message, v...) +func (e *Event) Error(message string, v ...any) *Event { + return e.Log(ErrorLevel, message, v...) } // Warn logs the event with log level warn -func (e *Event) Warn(message string, v ...any) { - e.maybeLog(WarnLevel, message, v...) +func (e *Event) Warn(message string, v ...any) *Event { + return e.Log(WarnLevel, message, v...) } // Info logs the event with log level info -func (e *Event) Info(message string, v ...any) { - e.maybeLog(InfoLevel, message, v...) +func (e *Event) Info(message string, v ...any) *Event { + return e.Log(InfoLevel, message, v...) } // Debug logs the event with log level debug -func (e *Event) Debug(message string, v ...any) { - e.maybeLog(DebugLevel, message, v...) +func (e *Event) Debug(message string, v ...any) *Event { + return e.Log(DebugLevel, message, v...) } // Trace logs the event with log level trace -func (e *Event) Trace(message string, v ...any) { - e.maybeLog(TraceLevel, message, v...) +func (e *Event) Trace(message string, v ...any) *Event { + return e.Log(TraceLevel, message, v...) } // Tag adds a "tag" field to the log event @@ -108,6 +108,14 @@ func (e *Event) Field(key string, value any) *Event { return e } +// FieldIf adds a custom field and value to the log event if the given level is loggable +func (e *Event) FieldIf(key string, value any, level Level) *Event { + if e.Loggable(level) { + return e.Field(key, value) + } + return e +} + // Fields adds a map of fields to the log event func (e *Event) Fields(fields Context) *Event { if e.fields == nil { @@ -138,7 +146,7 @@ func (e *Event) With(contexters ...Contexter) *Event { // to determine if they match. This is super complicated, but required for efficiency. func (e *Event) Render(l Level, message string, v ...any) string { appliedContexters := e.maybeApplyContexters() - if !e.shouldLog(l) { + if !e.Loggable(l) { return "" } e.Message = fmt.Sprintf(message, v...) @@ -153,11 +161,12 @@ func (e *Event) Render(l Level, message string, v ...any) string { return e.String() } -// maybeLog logs the event to the defined output, or does nothing if Render returns an empty string -func (e *Event) maybeLog(l Level, message string, v ...any) { +// Log logs the event to the defined output, or does nothing if Render returns an empty string +func (e *Event) Log(l Level, message string, v ...any) *Event { if m := e.Render(l, message, v...); m != "" { log.Println(m) } + return e } // Loggable returns true if the given log level is lower or equal to the current log level @@ -199,10 +208,6 @@ func (e *Event) String() string { return fmt.Sprintf("%s %s (%s)", e.Level.String(), e.Message, strings.Join(fields, ", ")) } -func (e *Event) shouldLog(l Level) bool { - return e.globalLevelWithOverride() <= l -} - func (e *Event) globalLevelWithOverride() Level { mu.RLock() l, ov := level, overrides diff --git a/server/config.go b/server/config.go index 80eb613..376862a 100644 --- a/server/config.go +++ b/server/config.go @@ -47,7 +47,6 @@ const ( DefaultVisitorMessageDailyLimit = 0 DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitReplenish = time.Hour - DefaultVisitorCallDailyLimit = 10 DefaultVisitorAccountCreationLimitBurst = 3 DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour DefaultVisitorAuthFailureLimitBurst = 30 @@ -106,10 +105,10 @@ type Config struct { SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string - TwilioMessagingBaseURL string TwilioAccount string TwilioAuthToken string TwilioFromNumber string + TwilioCallsBaseURL string TwilioVerifyBaseURL string TwilioVerifyService string MetricsEnable bool @@ -190,7 +189,7 @@ func NewConfig() *Config { SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", - TwilioMessagingBaseURL: "https://api.twilio.com", // Override for tests + TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests TwilioAccount: "", TwilioAuthToken: "", TwilioFromNumber: "", diff --git a/server/server.go b/server/server.go index 08cf08d..fb44801 100644 --- a/server/server.go +++ b/server/server.go @@ -91,6 +91,7 @@ var ( apiAccountSubscriptionPath = "/v1/account/subscription" apiAccountReservationPath = "/v1/account/reservation" apiAccountPhonePath = "/v1/account/phone" + apiAccountPhoneVerifyPath = "/v1/account/phone/verify" apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -463,12 +464,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath { return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe! + } else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhoneVerifyPath { + return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberVerify)))(w, r, v) } else if r.Method == http.MethodPut && r.URL.Path == apiAccountPhonePath { - return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberAdd))(w, r, v) - } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPhonePath { - return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberVerify))(w, r, v) + return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v) } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath { - return s.ensureUser(s.withAccountSync(s.handleAccountPhoneNumberDelete))(w, r, v) + return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiStatsPath { return s.handleStats(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == apiTiersPath { @@ -910,7 +911,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi return false, false, "", "", false, errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") - if call != "" && s.config.TwilioAccount == "" { + if call != "" && s.config.TwilioAccount == "" && s.userManager == nil { return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid diff --git a/server/server.yml b/server/server.yml index f11ad36..6728d6a 100644 --- a/server/server.yml +++ b/server/server.yml @@ -144,7 +144,7 @@ # smtp-server-domain: # smtp-server-addr-prefix: -# If enabled, ntfy can send SMS text messages and do voice calls via Twilio, and the "X-SMS" and "X-Call" headers. +# If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header. # # twilio-account: # twilio-auth-token: @@ -225,17 +225,11 @@ # visitor-request-limit-exempt-hosts: "" # Rate limiting: Hard daily limit of messages per visitor and day. The limit is reset -# every day at midnight UTC. If the limit is not set (or set to zero), the request limit (see above) -# governs the upper limit. SMS and calls are only supported if the twilio-settings are properly configured. +# every day at midnight UTC. If the limit is not set (or set to zero), the request +# limit (see above) governs the upper limit. # # visitor-message-daily-limit: 0 -# Rate limiting: Daily limit of SMS and calls per visitor and day. The limit is reset every day -# at midnight UTC. SMS and calls are only supported if the twilio-settings are properly configured. -# -# visitor-sms-daily-limit: 10 -# visitor-call-daily-limit: 10 - # Rate limiting: Allowed emails per visitor: # - visitor-email-limit-burst is the initial bucket of emails each visitor has # - visitor-email-limit-replenish is the rate at which the bucket is refilled diff --git a/server/server_account.go b/server/server_account.go index eb1c768..2330eab 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -521,7 +521,7 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi return nil } -func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { +func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { @@ -545,13 +545,13 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ } // Actually add the unverified number, and send verification logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification") - if err := s.verifyPhone(v, r, req.Number); err != nil { + if err := s.verifyPhoneNumber(v, r, req.Number); err != nil { return err } return s.writeJSON(w, newSuccessResponse()) } -func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { +func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { u := v.User() req, err := readJSONWithLimit[apiAccountPhoneNumberRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { @@ -560,7 +560,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R if !phoneNumberRegex.MatchString(req.Number) { return errHTTPBadRequestPhoneNumberInvalid } - if err := s.checkVerifyPhone(v, r, req.Number, req.Code); err != nil { + if err := s.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil { return err } logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified") diff --git a/server/server_middleware.go b/server/server_middleware.go index e0435bb..0e4aff7 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -85,6 +85,15 @@ func (s *Server) ensureAdmin(next handleFunc) handleFunc { }) } +func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + if s.config.TwilioAccount == "" { + return errHTTPNotFound + } + return next(w, r, v) + } +} + func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { if s.config.StripeSecretKey == "" || s.stripe == nil { diff --git a/server/server_twilio.go b/server/server_twilio.go index e543813..f806749 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/xml" "fmt" - "github.com/prometheus/client_golang/prometheus" "heckel.io/ntfy/log" "heckel.io/ntfy/user" "heckel.io/ntfy/util" @@ -15,24 +14,26 @@ import ( ) const ( - twilioCallEndpoint = "Calls.json" - twilioCallFormat = ` + twilioCallFormat = ` - + You have a notification from notify on topic %s. Message: %s End message. - This message was sent by user %s. It will be repeated up to five times. + This message was sent by user %s. It will be repeated up to three times. Goodbye. ` ) +// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified +// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number. +// If the user is anonymous, it will return an error. func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) { if u == nil { return "", errHTTPBadRequestAnonymousCallsNotAllowed @@ -66,11 +67,38 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { data.Set("From", s.config.TwilioFromNumber) data.Set("To", to) data.Set("Twiml", body) - s.twilioMessagingRequest(v, r, m, metricCallsMadeSuccess, metricCallsMadeFailure, twilioCallEndpoint, to, body, data) + ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request") + response, err := s.callPhoneInternal(data) + if err != nil { + ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request") + minc(metricCallsMadeFailure) + return + } + ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response") + minc(metricCallsMadeSuccess) } -func (s *Server) verifyPhone(v *visitor, r *http.Request, phoneNumber string) error { - logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Sending phone verification") +func (s *Server) callPhoneInternal(data url.Values) (string, error) { + requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", s.config.TwilioCallsBaseURL, s.config.TwilioAccount) + req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + response, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(response), nil +} + +func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber string) error { + ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Sending phone verification") data := url.Values{} data.Set("To", phoneNumber) data.Set("Channel", "sms") @@ -86,21 +114,16 @@ func (s *Server) verifyPhone(v *visitor, r *http.Request, phoneNumber string) er return err } response, err := io.ReadAll(resp.Body) - ev := logvr(v, r).Tag(tagTwilio) if err != nil { ev.Err(err).Warn("Error sending Twilio phone verification request") return err } - if ev.IsTrace() { - ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response") - } else if ev.IsDebug() { - ev.Debug("Received successful Twilio phone verification response") - } + ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response") return nil } -func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code string) error { - logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification") +func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error { + ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification") data := url.Values{} data.Set("To", phoneNumber) data.Set("Code", code) @@ -111,10 +134,6 @@ func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code } req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - log.Fields(httpContext(req)).Field("http_body", data.Encode()).Info("Twilio call") - ev := logvr(v, r). - Tag(tagTwilio). - Field("twilio_to", phoneNumber) resp, err := http.DefaultClient.Do(req) if err != nil { return err @@ -144,54 +163,6 @@ func (s *Server) checkVerifyPhone(v *visitor, r *http.Request, phoneNumber, code return nil } -func (s *Server) twilioMessagingRequest(v *visitor, r *http.Request, m *message, msuccess, mfailure prometheus.Counter, endpoint, to, body string, data url.Values) { - logContext := log.Context{ - "twilio_from": s.config.TwilioFromNumber, - "twilio_to": to, - } - ev := logvrm(v, r, m).Tag(tagTwilio).Fields(logContext) - if ev.IsTrace() { - ev.Field("twilio_body", body).Trace("Sending Twilio request") - } else if ev.IsDebug() { - ev.Debug("Sending Twilio request") - } - response, err := s.performTwilioMessagingRequestInternal(endpoint, data) - if err != nil { - ev. - Field("twilio_body", body). - Field("twilio_response", response). - Err(err). - Warn("Error sending Twilio request") - minc(mfailure) - return - } - if ev.IsTrace() { - ev.Field("twilio_response", response).Trace("Received successful Twilio response") - } else if ev.IsDebug() { - ev.Debug("Received successful Twilio response") - } - minc(msuccess) -} - -func (s *Server) performTwilioMessagingRequestInternal(endpoint string, data url.Values) (string, error) { - requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/%s", s.config.TwilioMessagingBaseURL, s.config.TwilioAccount, endpoint) - req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) - if err != nil { - return "", err - } - req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", err - } - response, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - return string(response), nil -} - func xmlEscapeText(text string) string { var buf bytes.Buffer _ = xml.EscapeText(&buf, []byte(text)) diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 133138f..5b32095 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -11,37 +11,108 @@ import ( "testing" ) -func TestServer_Twilio_SMS(t *testing.T) { - var called atomic.Bool - twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { + var called, verified atomic.Bool + var code atomic.Pointer[string] + twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) require.Nil(t, err) - require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Messages.json", r.URL.Path) require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "Body=test%0A%0A--%0AThis+message+was+sent+by+9.9.9.9+via+ntfy.sh%2Fmytopic&From=%2B1234567890&To=%2B11122233344", string(body)) + if r.URL.Path == "/v2/Services/VA1234567890/Verifications" { + if code.Load() != nil { + t.Fatal("Should be only called once") + } + require.Equal(t, "Channel=sms&To=%2B12223334444", string(body)) + code.Store(util.String("123456")) + } else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" { + if verified.Load() { + t.Fatal("Should be only called once") + } + require.Equal(t, "Code=123456&To=%2B12223334444", string(body)) + verified.Store(true) + } else { + t.Fatal("Unexpected path:", r.URL.Path) + } + })) + defer twilioVerifyServer.Close() + twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if called.Load() { + t.Fatal("Should be only called once") + } + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) + require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) + require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+notification+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+up+to+three+times.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) called.Store(true) })) - defer twilioServer.Close() + defer twilioCallsServer.Close() - c := newTestConfig(t) - c.BaseURL = "https://ntfy.sh" - c.TwilioMessagingBaseURL = twilioServer.URL + c := newTestConfigWithAuthFile(t) + c.TwilioVerifyBaseURL = twilioVerifyServer.URL + c.TwilioCallsBaseURL = twilioCallsServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" - c.VisitorSMSDailyLimit = 1 + c.TwilioVerifyService = "VA1234567890" s := newTestServer(t, c) - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "SMS": "+11122233344", + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + u, err := s.userManager.User("phil") + require.Nil(t, err) + + // Send verification code for phone number + response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444"}`, map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), }) - require.Equal(t, "test", toMessage(t, response.Body.String()).Message) + require.Equal(t, 200, response.Code) + waitFor(t, func() bool { + return *code.Load() == "123456" + }) + + // Add phone number with code + response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + waitFor(t, func() bool { + return verified.Load() + }) + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + require.Nil(t, err) + require.Equal(t, 1, len(phoneNumbers)) + require.Equal(t, "+12223334444", phoneNumbers[0]) + + // Do the thing + response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "yes", + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) waitFor(t, func() bool { return called.Load() }) + + // Remove the phone number + response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + }) + require.Equal(t, 200, response.Code) + + // Verify the phone number is gone from the DB + phoneNumbers, err = s.userManager.PhoneNumbers(u.ID) + require.Nil(t, err) + require.Equal(t, 0, len(phoneNumbers)) } -func TestServer_Twilio_SMS_With_User(t *testing.T) { +func TestServer_Twilio_Call_Success(t *testing.T) { var called atomic.Bool twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if called.Load() { @@ -49,16 +120,15 @@ func TestServer_Twilio_SMS_With_User(t *testing.T) { } body, err := io.ReadAll(r.Body) require.Nil(t, err) - require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Messages.json", r.URL.Path) + require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "Body=test%0A%0A--%0AThis+message+was+sent+by+phil+%289.9.9.9%29+via+ntfy.sh%2Fmytopic&From=%2B1234567890&To=%2B11122233344", string(body)) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+notification+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+up+to+three+times.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) called.Store(true) })) defer twilioServer.Close() c := newTestConfigWithAuthFile(t) - c.BaseURL = "https://ntfy.sh" - c.TwilioMessagingBaseURL = twilioServer.URL + c.TwilioCallsBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -68,62 +138,26 @@ func TestServer_Twilio_SMS_With_User(t *testing.T) { require.Nil(t, s.userManager.AddTier(&user.Tier{ Code: "pro", MessageLimit: 10, - SMSLimit: 1, + CallLimit: 1, })) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + u, err := s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) - // Do request with user - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - "SMS": "+11122233344", + // Do the thing + response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "+11122233344", }) - require.Equal(t, "test", toMessage(t, response.Body.String()).Message) - waitFor(t, func() bool { - return called.Load() - }) - - // Second one should fail due to rate limits - response = request(t, s, "POST", "/mytopic", "test", map[string]string{ - "Authorization": util.BasicAuth("phil", "phil"), - "SMS": "+11122233344", - }) - require.Equal(t, 42910, toHTTPError(t, response.Body.String()).Code) -} - -func TestServer_Twilio_Call(t *testing.T) { - var called atomic.Bool - twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - require.Nil(t, err) - require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) - require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EYou+have+a+message+from+notify+on+topic+mytopic.+Message%3A%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3Ethis+message+has%26%23xA%3Ba+new+line+and+%26lt%3Bbrackets%26gt%3B%21%26%23xA%3Band+%26%2334%3Bquotes+and+other+%26%2339%3Bquotes%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EEnd+message.%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EThis+message+was+sent+by+9.9.9.9+via+127.0.0.1%3A12345%2Fmytopic%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%3C%2FResponse%3E", string(body)) - called.Store(true) - })) - defer twilioServer.Close() - - c := newTestConfig(t) - c.TwilioMessagingBaseURL = twilioServer.URL - c.TwilioAccount = "AC1234567890" - c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioFromNumber = "+1234567890" - c.VisitorCallDailyLimit = 1 - s := newTestServer(t, c) - - body := `this message has -a new line and ! -and "quotes and other 'quotes` - response := request(t, s, "POST", "/mytopic", body, map[string]string{ - "x-call": "+11122233344", - }) - require.Equal(t, "this message has\na new line and !\nand \"quotes and other 'quotes", toMessage(t, response.Body.String()).Message) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) waitFor(t, func() bool { return called.Load() }) } -func TestServer_Twilio_Call_With_User(t *testing.T) { +func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) { var called atomic.Bool twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if called.Load() { @@ -133,13 +167,44 @@ func TestServer_Twilio_Call_With_User(t *testing.T) { require.Nil(t, err) require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EYou+have+a+message+from+notify+on+topic+mytopic.+Message%3A%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3Ehi+there%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EEnd+message.%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay%3EThis+message+was+sent+by+phil+%289.9.9.9%29+via+127.0.0.1%3A12345%2Fmytopic%3C%2FSay%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%3C%2FResponse%3E", string(body)) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+notification+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+up+to+three+times.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) called.Store(true) })) defer twilioServer.Close() c := newTestConfigWithAuthFile(t) - c.TwilioMessagingBaseURL = twilioServer.URL + c.TwilioCallsBaseURL = twilioServer.URL + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + + // Add tier and user + require.Nil(t, s.userManager.AddTier(&user.Tier{ + Code: "pro", + MessageLimit: 10, + CallLimit: 1, + })) + require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser)) + require.Nil(t, s.userManager.ChangeTier("phil", "pro")) + u, err := s.userManager.User("phil") + require.Nil(t, err) + require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344")) + + // Do the thing + response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + "authorization": util.BasicAuth("phil", "phil"), + "x-call": "yes", // <<<------ + }) + require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) + waitFor(t, func() bool { + return called.Load() + }) +} + +func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) { + c := newTestConfigWithAuthFile(t) + c.TwilioCallsBaseURL = "http://dummy.invalid" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -155,19 +220,16 @@ func TestServer_Twilio_Call_With_User(t *testing.T) { require.Nil(t, s.userManager.ChangeTier("phil", "pro")) // Do the thing - response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{ + response := request(t, s, "POST", "/mytopic", "test", map[string]string{ "authorization": util.BasicAuth("phil", "phil"), "x-call": "+11122233344", }) - require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message) - waitFor(t, func() bool { - return called.Load() - }) + require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code) } func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { c := newTestConfig(t) - c.TwilioMessagingBaseURL = "https://127.0.0.1" + c.TwilioCallsBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" @@ -176,29 +238,21 @@ func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { response := request(t, s, "POST", "/mytopic", "test", map[string]string{ "x-call": "+invalid", }) - require.Equal(t, 40031, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code) } -func TestServer_Twilio_SMS_InvalidNumber(t *testing.T) { +func TestServer_Twilio_Call_Anonymous(t *testing.T) { c := newTestConfig(t) - c.TwilioMessagingBaseURL = "https://127.0.0.1" + c.TwilioCallsBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" s := newTestServer(t, c) response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "x-sms": "+invalid", + "x-call": "+123123", }) - require.Equal(t, 40031, toHTTPError(t, response.Body.String()).Code) -} - -func TestServer_Twilio_SMS_Unconfigured(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "POST", "/mytopic", "test", map[string]string{ - "x-sms": "+1234", - }) - require.Equal(t, 40030, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code) } func TestServer_Twilio_Call_Unconfigured(t *testing.T) { @@ -206,5 +260,5 @@ func TestServer_Twilio_Call_Unconfigured(t *testing.T) { response := request(t, s, "POST", "/mytopic", "test", map[string]string{ "x-call": "+1234", }) - require.Equal(t, 40030, toHTTPError(t, response.Body.String()).Code) + require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code) } diff --git a/server/types.go b/server/types.go index cbf1df9..8fd7517 100644 --- a/server/types.go +++ b/server/types.go @@ -326,7 +326,6 @@ type apiAccountLimits struct { Messages int64 `json:"messages"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"` Emails int64 `json:"emails"` - SMS int64 `json:"sms"` Calls int64 `json:"calls"` Reservations int64 `json:"reservations"` AttachmentTotalSize int64 `json:"attachment_total_size"` @@ -340,8 +339,6 @@ type apiAccountStats struct { MessagesRemaining int64 `json:"messages_remaining"` Emails int64 `json:"emails"` EmailsRemaining int64 `json:"emails_remaining"` - SMS int64 `json:"sms"` - SMSRemaining int64 `json:"sms_remaining"` Calls int64 `json:"calls"` CallsRemaining int64 `json:"calls_remaining"` Reservations int64 `json:"reservations"` diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 7d8affc..f2120e5 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -130,7 +130,7 @@ "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "publish_dialog_email_reset": "Remove email forward", "publish_dialog_call_label": "Phone call", - "publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444", + "publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444, or 'yes'", "publish_dialog_call_reset": "Remove phone call", "publish_dialog_attach_label": "Attachment URL", "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 21b3f81..b5bfcd2 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -1,7 +1,7 @@ import { accountBillingPortalUrl, accountBillingSubscriptionUrl, - accountPasswordUrl, accountPhoneUrl, + accountPasswordUrl, accountPhoneUrl, accountPhoneVerifyUrl, accountReservationSingleUrl, accountReservationUrl, accountSettingsUrl, @@ -299,8 +299,8 @@ class AccountApi { return await response.json(); // May throw SyntaxError } - async verifyPhone(phoneNumber) { - const url = accountPhoneUrl(config.base_url); + async verifyPhoneNumber(phoneNumber) { + const url = accountPhoneVerifyUrl(config.base_url); console.log(`[AccountApi] Sending phone verification ${url}`); await fetchOrThrow(url, { method: "PUT", @@ -311,11 +311,11 @@ class AccountApi { }); } - async checkVerifyPhone(phoneNumber, code) { + async addPhoneNumber(phoneNumber, code) { const url = accountPhoneUrl(config.base_url); - console.log(`[AccountApi] Checking phone verification code ${url}`); + console.log(`[AccountApi] Adding phone number with verification code ${url}`); await fetchOrThrow(url, { - method: "POST", + method: "PUT", headers: withBearerAuth({}, session.token()), body: JSON.stringify({ number: phoneNumber, diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 6e04491..346df37 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -28,6 +28,7 @@ export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/ac export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; +export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 28d24a3..b4a378e 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -432,7 +432,7 @@ const AddPhoneNumberDialog = (props) => { const verifyPhone = async () => { try { setSending(true); - await accountApi.verifyPhone(phoneNumber); + await accountApi.verifyPhoneNumber(phoneNumber); setVerificationCodeSent(true); } catch (e) { console.log(`[Account] Error sending verification`, e); @@ -449,7 +449,7 @@ const AddPhoneNumberDialog = (props) => { const checkVerifyPhone = async () => { try { setSending(true); - await accountApi.checkVerifyPhone(phoneNumber, code); + await accountApi.addPhoneNumber(phoneNumber, code); props.onClose(); } catch (e) { console.log(`[Account] Error confirming verification`, e); diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index c410f19..0353abe 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -45,7 +45,6 @@ const PublishDialog = (props) => { const [filename, setFilename] = useState(""); const [filenameEdited, setFilenameEdited] = useState(false); const [email, setEmail] = useState(""); - const [sms, setSms] = useState(""); const [call, setCall] = useState(""); const [delay, setDelay] = useState(""); const [publishAnother, setPublishAnother] = useState(false); @@ -54,7 +53,6 @@ const PublishDialog = (props) => { const [showClickUrl, setShowClickUrl] = useState(false); const [showAttachUrl, setShowAttachUrl] = useState(false); const [showEmail, setShowEmail] = useState(false); - const [showSms, setShowSms] = useState(false); const [showCall, setShowCall] = useState(false); const [showDelay, setShowDelay] = useState(false); @@ -128,9 +126,6 @@ const PublishDialog = (props) => { if (email.trim()) { url.searchParams.append("email", email.trim()); } - if (sms.trim()) { - url.searchParams.append("sms", sms.trim()); - } if (call.trim()) { url.searchParams.append("call", call.trim()); } @@ -416,27 +411,6 @@ const PublishDialog = (props) => { /> } - {showSms && - { - setSms(""); - setShowSms(false); - }}> - setSms(ev.target.value)} - disabled={disabled} - type="tel" - variant="standard" - fullWidth - inputProps={{ - "aria-label": t("publish_dialog_sms_label") - }} - /> - - } {showCall && { setCall(""); @@ -562,7 +536,6 @@ const PublishDialog = (props) => {
{!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showSms && setShowSms(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showCall && setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} From 5e18ced7d2ba600d13cd97d3ad0ca77beee8aae3 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 May 2023 15:02:53 -0400 Subject: [PATCH 19/97] Docs --- docs/config.md | 17 +++++++++++++++ docs/publish.md | 29 +++++++++++++++++++------- docs/static/audio/ntfy-phone-call.mp3 | Bin 0 -> 59728 bytes docs/static/audio/ntfy-phone-call.ogg | Bin 0 -> 51664 bytes 4 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 docs/static/audio/ntfy-phone-call.mp3 create mode 100644 docs/static/audio/ntfy-phone-call.ogg diff --git a/docs/config.md b/docs/config.md index fa59938..353a9d0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -814,6 +814,7 @@ ntfy tier add \ --message-limit=10000 \ --message-expiry-duration=24h \ --email-limit=50 \ + --call-limit=10 \ --reservation-limit=10 \ --attachment-file-size-limit=100M \ --attachment-total-size-limit=1G \ @@ -854,6 +855,22 @@ stripe-webhook-key: "whsec_ZnNkZnNIRExBSFNES0hBRFNmaHNka2ZsaGR" billing-contact: "phil@example.com" ``` +## Phone calls +ntfy supports phone calls via [Twilio](https://www.twilio.com/) as a phone call provider. If phone calls are enabled, +users can verify and add a phone number, and then receive phone calls when publish a message with the `X-Call` header. +See [publishing page](publish.md#phone-calls) for more details. + +To enable Twilio integration, sign up with [Twilio](https://www.twilio.com/), purchase a phone number (Toll free numbers +are the easiest), and then configure the following options: + +* `twilio-account` is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 +* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586 +* `twilio-from-number` is the outgoing phone number you purchased, e.g. +18775132586 +* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 + +After you have configured phone calls, create a [tier](#tiers) with a call limit, and then assign it to a user. +Users may then use the `X-Call` header to receive a phone call when publishing a message. + ## Rate limiting !!! info Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag. diff --git a/docs/publish.md b/docs/publish.md index 2491671..98f3e87 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2718,7 +2718,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl curl \ -u :tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ -H "Call: +12223334444" \ - -d "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help." \ + -d "Your garage seems to be on fire. You should probably check that out." \ ntfy.sh/alerts ``` @@ -2727,7 +2727,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl ntfy publish \ --token=tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 \ --call=+12223334444 \ - alerts "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help." + alerts "Your garage seems to be on fire. You should probably check that out." ``` === "HTTP" @@ -2737,14 +2737,14 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2 Call: +12223334444 - Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help. + Your garage seems to be on fire. You should probably check that out. ``` === "JavaScript" ``` javascript fetch('https://ntfy.sh/alerts', { method: 'POST', - body: "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.", + body: "Your garage seems to be on fire. You should probably check that out.", headers: { 'Authorization': 'Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2', 'Call': '+12223334444' @@ -2755,7 +2755,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl === "Go" ``` go req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts", - strings.NewReader("Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.")) + strings.NewReader("Your garage seems to be on fire. You should probably check that out.")) req.Header.Set("Call", "+12223334444") req.Header.Set("Authorization", "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2") http.DefaultClient.Do(req) @@ -2770,7 +2770,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl Authorization = "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2" Call = "+12223334444" } - Body = "Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help." + Body = "Your garage seems to be on fire. You should probably check that out." } Invoke-RestMethod @Request ``` @@ -2778,7 +2778,7 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl === "Python" ``` python requests.post("https://ntfy.sh/alerts", - data="Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.", + data="Your garage seems to be on fire. You should probably check that out.", headers={ "Authorization": "Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", "Call": "+12223334444" @@ -2794,11 +2794,24 @@ On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) pl "Content-Type: text/plain\r\n" . "Authorization: Bearer tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2\r\n" . "Call: +12223334444", - 'content' => 'Your garage seems to be on fire. You should probably check that out, and call 0118 999 881 999 119 725 3 for help.' + 'content' => 'Your garage seems to be on fire. You should probably check that out.' ] ])); ``` +Here's what a phone call from ntfy sounds like: + + + +Audio transcript: + +> You have a notification from ntfy on topic alerts. +> Message: Your garage seems to be on fire. You should probably check that out. End message. +> This message was sent by user phil. It will be repeated up to three times. + ## Authentication Depending on whether the server is configured to support [access control](config.md#access-control), some topics may be read/write protected so that only users with the correct credentials can subscribe or publish to them. diff --git a/docs/static/audio/ntfy-phone-call.mp3 b/docs/static/audio/ntfy-phone-call.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..0cace65f3e1f56d6f4b5208053133afa28ee7139 GIT binary patch literal 59728 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%0D}4K8bvKvQtIaa1I-a`dPavLihx zSZT;LWlH2wcKR)y?(Rs+|}O<0y*zHdr*LPTNRDksKacwMZAiCOZcEz}%Vp_t__~nB?)-F+`v$lu7h1mI;7_4rzgmBgORg3!K!F{5w*tVh``u z@?nC2Y`GBYBxU}aA}iiZdKUCD7SbajFIdMiv-0^MqR?_X$#*S$)mK@R$R1Z+WLP7P z*L75lwF$*cd%@ij4*8S(hE3!L$GQ*xukQwG)cIFRGvtON^Cyv>jGVqy4$#|=<4(qB zFEgu1&yabeW?tfOU)KAqjMsF;Ib*k8g>LWS`t_8GoxPg9ajs{wSOpnaFLGowhsDSE zxxcSy%*(rNBIS@^X4%CuR;8tFOy6`N`!03j-eXS9c;kl6Xk(Pid;JnVx=Y6*U}+q7 z#Y7cYhKNIlfGxFq(P5C7k~14!;u+-(=Q4|x;!Hd32k8<&w^m5cns}DW$10p$s0rdJ zW4?yWMMK*s!MR95^`Ze9xU@b23JDL9YdQkiP$GSFJ88{4Ek`O_6lCoibh&d$3(IO9 z?XeGbQ!8|1zvSm0jt0hyVaj-(F%sTG#7>e4n~db3S1+;$MRseFI}+5{JI>4N{1$)I zq0(s<<32!t8H-g5Ywsxv&w5_HMsdlRrP~X~qe|!!_eL5v8dn}IU9JBy|KjaVF{N{A zj}mBxaV<&d;o?7^rh#EXPE?xRlv&{JDbE^%E(7GAffUg{xnHES$lb*mF{uACN6&K-FvpuuGNq_ez_UB zem+P~C)nS#I||P1Ld?>mLG9c1jpIRdX_h%pmUMLxi{;h66SODtZc3attiGBWjY{24 zEzx_9cp!SJy5aWO!MTZ%v}E6Qo$CT>Bi#u}4C)IpzSa*w)+UW)ho_M=vt2v;&`l<# zwcUzH#evhG(EH^64Ytkg&!5QCYy0ZQXZG<=nh+EOZW{0(IkeTz|kpTiK@xyNUEbH8&kYIg$2pf3T13vB5CPsF2D&F_fr>Onb! zGV?MevZ74+g((!kv3-1cH_xq1(Q`3j;(Ld9y{u4<-^|Ug9N>B@6}4Z&-%3|@TTQq0 zl41(F-tWzaDdgCTq07?Imf=P9Nj4MUTW1eKKv2`d+XW%0(Oa%oZ12rF4xDX_Kw%HqHrm-LF_@3|HCk1PW|!l1dBD4&f7dXVLVI5y?h( z9Sp*>6FT+46*Os*g+5_o-42e&@o0N@U`xnFYlitRNxP9B^z7>FO+O_c;98MxyOtYB zJ(mq7dGP1a^)?w}<)v*US)-k4SfKRxxe}$l8wI?%&(;`@o)z`xxIs^^HQW{BQt*6I z0K@XMLy$eN@NAK9q};>E!jTY6k|aGKE=vESzPAxW<+oSt*Esi^xC!FEFHQ~wPdRdr zfh08`Wylo2i=`FBre%={+HhNGq`Xkg;`K9eGmwCdM@oh>{i}@iuCQ9Gl;#yvL4niD zl}#hmeb->d2mz}6AZ&psQHRCE&NRN@pXc3Ef4X>FV_zAo>0gU_hVC8>+FhpjKn~*1 zo?a+b?<=_rpyjT@!tlJZiqxK4U>E_F>C!1v;tD`70v8ITA}EZGSITp9Fy~3L2zefz zwx(N)B=fUOm8rx8vHZ10GlcDF_gGm@NjTl55z&N^u_omo*FbEV1Oo|907NsnAT}R! zRI(hZsnm2OQNDxFTiE5hOMTg2o0?um~#Kr3wyT&TphLaZ0Eo zHtLJZAQ43hr7n;{`H~OSuBLqNrBHmyC@L$cz@J*(pts*CKRJCozk&ka3#52zhUApM z;qZBz=x+*(4;e$+)o!+B6#ZoVP+R=P>!9CzMNjqFS#-k=^iqByhw@jf{X%i}zc>A* zX!3s$m3~?LiTjTlzw>|ouiHZz`=)I1(?5Uo#esi*%TG;}$@xnVR)91lmf(3UcxVr{|6A13^?s9JHDyXV)_Bi%D+&WNJ9xe5RY{PTFoB$>aGh@W?2Ac!H{?467(onIp$ zL`Yu$0KmZZ@Bb7C#n+VoeqK{TfVnVVY6~Q_mw!EVP=D*-07Dv<4i-#`PUfUGmPTrS zX(p8>Wn*DuVc}-sAf;6{HE}hzv@<7tZ|7#|WN&9{YUe`tsuC6a6IK2st@26G&c)1= z$=Lz|=5MVSV&ZCG2?zjL8H#B8&y30hi%+s=-^hoBFX6iwZCl@}@&V(QFzv9`Iee8P#voi-%T9Ph z=aagbWf`j;T;mAV-kwbr+JNyG)z@+YBG3?jl^GELekmcp|LFe;h`<8=0M>g)tUgCV zsc)1+kbjAY0IznTjoP67Mj`#pL^IJ;GqJ!k@ke7Z^Fuk)QTfSX6KYU!DKyXz0Jyp&$wm<$M`KiwSz%a%$LYSHPb=`|Ba_36f5y2?nmVqU6ijwXpl^Erv%ZQhgp)IRTlty4om?hs! zk5`bP&acH$9?FZK6=Q$_0B9cYPXqo-a49_S9RSb+W0XJ8b;lbiu!h7dN741gC=c`Y zBs)d0?vs8RX6+{o`asu%>#V@LOKO5lze^Z|%=T(Su$3Wj#B#_OApLnDfIv#i)O^eD z7M7{BUIjmjzta{M?|2p*q!bi;eJ?65C~hh*DlWJ2%(Q$(GwF(p!K`(0@ljZ@)lfmn zV*NpJ{qbP6U2}EmQfBdBeNj`jU2(Z>bG6M;`Mag2ZO`Jvl!7C#f`g{wqs8jOrs@+g z8&=G)RB$v{eKc5KHdtM54MxA0ES7`Qo(#UGEqy3BI;=k&Y$_kDK3%FWf2b})YTj?E zFCQ!~FR3m)s%AW@w^#EPm&&`0e=Rk(_wrg*}tm?w-7u7`9wONp9vTFuQ6SzS2$4ABQj@rtPdRdN| zSW260AIr;-x=N0EtH6=VZP8XsZRdyetSwD;gw2%%?I#btRYk?NY2_tH1q??`Hb-st zM@^0qV7B<(L(}2lD|>#_#*CbMbl5~c)Ks}xT`|vXZnVYr$Jx#5hV zuL4PYrvA&ujB9SmQm;Ky*Ce-F8vyu2z=6FSb5P`8CWa-0$2LZ%?$JIVW@C~s0(x~NV` z#u}_jMy4u%QpUEd2VcgLF>g`}3`{HAmepxdfHRK}9Y~K;mXR(3=dqg}kIF}%68KGq zt|;M~a%pnhq$n*_V1$fxe%&`2hQC#PQP#39n@Lud<&6IZR@DQ}qpS{Jg@N;nk}?vw z$AF7lr)F)%Sg#~zos)+nOV3(|V+_t57YyWKXwwz-;Alsd)oG~6awdK=vMHOHmto|c z)v}ghoSjsTD631>mTT zR;6vjS&(RCQ#GTZ9M0OS;VfG>6R#Bx24opHdvUGpICqo*pu-;u5b;Ma%^r_Q07S6B zV?45Pgs26U(=abxr1B^)4V5#h7(AWns3<&H{wObee(f|bTuywv7#&?cmNqR_{;bwJ z0Lbou07yGv1zJ}y{QK9=#QXg{v=s3;vC zn4+NyLKmaU2M4tI@!&BCkq|ua*^I|)5P}PY4e@t@jF4G_NnMor=-vd$$Ce?TbvGRtO7)jMe?9=+)u006%7qlt4sY&Lkfh6FspF3u_gZmfJsx?a*eEbZ3{D_fNrA&Y_SqyhHs z{8^)8(!6P{h&negz}TxL{t8%+z5=D3bFaYeviB?CJlY5V%P??2B^1GXE*e|}`hQgd ztk^h$HzeLDf^{!lT!ytb*?E+%7u=S-d#?bv=&t~neFa`waF87s^Gc12?!U6|WG>)1 zaNYk$mDqT#M7**LT|PMYk^2t?N%qSA75D!-NceB_0Z7*n;eZ!70&@*%J_bS*rq^~r z2fr$CyKsXdUK=}@6+YiZK}MR2GeVR$-x&j(K5$&i3f$|JW$=RHjgZI^;;rGDp&B^zN~Im%a$=O zUdx)Xch<;;DnC)nx(M9e5ua{&B5R5}JH;X`(BQ?c7W$qxk4adeWD0J`y3=)8p13p6p1bl>t40;0(jsg2Wa0>t*5dFV? zwb(^QMaRIx!Nn&eCMBo*3;id6{%4-}{%`(uo(S&bz`%S)o+Kg;HZ~5<|1vPMvNt!j zwzaXfH@CE~wz08sw70gjw6d|Wa&+==wXuV`VFWCol{!dXDO_V@IqK*T=niqi59Uy` zE~9_pxW>>*$u`800GgRZngQ7$tk@CdymME*H{Y~V>> zQQz;*Z_Qik+Pr^ZXO`pGkJY%brHJC!{wkCd290Ge`%4;PZqby77{$YQgdNd>eMhJ$ zrzte9>CJ{XAzhX4S2GJ($-~{{75Cp}Ui@lMC2xGwW@QHffXN@yAK?QA@RdDWzSOrR zOXRx;e^KSpzK&X!*$%19+vorpf!1OvU*v=9XnDEP^5IMw7cbULFKgQMpq`#StF=10 zru&x#v0vk;`;8mx%OiFw&$~2NzXaWeZ7W_t%+i;LzIwlM*#Lm(tBnm$2eY^$CC0&JprL9P22+2W3_ zjyL!)2YFa`EQ&=|^_*CO6aiMmQt756nA0oKyWXF9O?gZa1}%VKon_|*gJ(Z#QFW3~ zd>$+~(qyxeIgNa@9G8b;?vbQC1%;%YuCWDaeTZKT3Q<^aiv<7 zX($`*nQbSlrtG#i(5RP190jl5^Gkfm(5>2?_oHPPqOW!f0$O@ISH}$;WQ>JBf(Jej z8Q(_5wo6SG=A#1|nSXQ)P-+rV(29ujs-K_k-8nt1ZcHyz`!q8cKatz`-00r>TvPJ) z#T}DuJOQiI`6KanGM4p@E*pv3XWhhIlZ=ZCo3D|5sttlT!DNA6cz_6;C1CI!3+QMD z=I{JK0yb+0dAlwLQOn;>Tf6OxzrWjB*PTl=qPi~sqH3>T!+Om=8!!Fa%117^jV?)j z1zT`|*Y7Cx=D>vV-Eh&S`QSPV1Ueu|++n$9F>p`KklUX_ z(Pu_P&==!|%&g`)$ba9?=*kM z>W)Z|5<=3o>z^82DHaPrU#;6B*M3g<=_YH+2-SFIHmj)dtd#ckcJ0S{L4;+e(!IUC zLAowN{f@tH|H)fKcUdw8{j?hIQgnAI{%fjsAAWD0SfqPn0=aur^q-yr-n;B^hn?0Ff^-kMJSLFFZ{|_Q$ zX>hgMX1;HcF4iwkJE4ZMW>vqVtamB9T~)cOn_+rWga)vz+5fql{_6g6S7U-ZArF1k zlp%ypKXM_Id&qb`K>hy8n$j7n^iO7 z+Hmi0JTJBMUocNIGaa0~>pD=CP{0q66M3IQe67FM{JlCc*)oYNzA9e{1*U00ow_o* z4$Y!hi2&c!jHm$~StyN-kJY^so-2tbAz|AM^gH`C=XsTzhx(JX zU8iNf2z{+9u0dMLc$U0)rWs|&0|u`6`wb%zU58&v>h9BzruPR9ux^orD&1#e&k?c z#I_WFK@YNLnP%dJ2-YI%fqD69{XI)08Y9gB1(+xFh4BA+Qt{HUf}UsUb6dr*qfM+y zLMs?XdaLkjU!|#*G(v%2Ol7FHJ}F6fe#oMD?acL!5ej!kO~$>4hk)kjNec`t1MiNV z>BzwVQzzDHM0K#phTw)l#augt|PR$w19L*=IoKX6aGIfqQjAzmy^Y?8KpcBAOm!dJ7J#n)^%HP*WXgj=Kbc^+6ar$^JxLo`Es9Y^rWb2&hLiA2 z!$(6m63$-E2KkS7T>Py1PpTuI*Z73 z2X!XNqhWSNhLjke&B{tIRtpgbm%$|5@}+o&2bx}eju}ttewldXc9YT_&L0b=&m>;O z&1iX7=Y5pP^V^VS@p{xJHst{BwY6B(&hX0~XGc4{bC#q|=qUzYVoTroCO#WV$+B1p z6n~QR$NT3oT}w0hhEn_nrx%&jXjXjb$(5DH>46=Nw3y>z51y6jfwP%Ub(y|IS2v=A zE2xmM=lvfTv|gMf&5@nHhgljOwg_vG+{V~ibuL}b4RZn9t>EON89hn7#K0UQtt*KpvGZXhqc(N-EX+ zL9`8x=8$Z8TAXSCF6u0S%q=swyvAWtdwpT%93$w8S1-#o?12N?D7>P#r;9j>ijkMs6#5D^Uc6~G>Cej4UneHlMc!!&`d-Q*%khQ^A4*ArXn zn2Top!w$%c`a`e7P1WT2NX1#VeKdKFSetx!dafaG$0WF8pWD1k?auMD3Y_m>@2wH) z?4OjjAC?=@zHdPctS!e;VCtfxgT*9C6<~DxZ2O0#of@X-Yl=rk1OdKUVRkx1fZB=$ zT6u-SKEJy>5Iq2@(&NAcKIZ8#gbFBctLnD6R$x<~RS+WH3|{Yf}4>gC)?jMcUf~P$`8x>HJt`0}D*OBuX zH0+X#QE5(<3pex2)OV8~I3wED6cs*Fcz*au)!D>7@C&w*rL4Dv#@q9BTbatf|2;R& zqlG0X(MR{q8$vw5x4>aPehd8_`*||@aWNF93MB5!^Zkp>p|`X7mGg(C)hsF-b?*hX zF5lln-Q9NSTRo%C$J!L2s>W-BtUp|F`eVL-ii~e6)>6D`ThkbLNS zDDb(v+yyTeE8H+jnkSyTOZYR(oHd-8lB|PL@{$GeUBMk&RvcBN9CUmBxpUD@D)v$q z^;T1?oFc4kDdB@>eOPv*zup1SR~JUp7^10lt;8yOtFjapoNyqRSg(u_@V{g*_#8nm zlH23kRd1@_T^|R_0&OLYo2U4$bojDs%gUtp%1`>oPC2Aup3+5Ub3OB}H|S|9+&zQ| z^mFB9DhCb~O}RenOd;8k(3Vw;_?<;QZm(sHx5=MBpF1@hX_;!O*qvI_6L$+{WXwjO z1BivXU|!nb1QG7B#(_06*rBCG2S@@90hd}VIuoCYMWyz3&UYWgZd^pkzin;h4zYM1 zuQgk9Ye(l3ou0}2>^pnxWgT>S+f|6BvHx7%jAvV*2a1$p;ozc_d9{=tF5@ZqWwnnI z7*xz(huGJ>Et~&Xvw#AV8r7(AG>#5VPWBGQW)|R82`dW&YcoS*Yexq=dkb?1b0=4O zS34_5D^oKQ-;F_)cnH9S!-KE`3K0cp_?C@Ym65)hP)C!+L}fp-@{pm%V2fgEh2^W? z`uuw*>6(snx+(RjB2v=R<^1rW1a1kVff0$u$(&7_QhlQ^;DBM0aY)#oy z+Gj;@EtcQg*5FREZ!YhgMNp*>&vxb6I`Sp5YDNkl0E!24Je5FJ?Ns-7$RT0;>joV;`3@B>fFCJ4D@QEL>L}LntHS1V+dHEx13){=UCW`5+ ze!z2aB$c+mTyB%hCCJx)(oj)9A~sLfHNb^9K&zoTRt|@vRgA}l zS=@DMF}}RuHsVozNwLPy)mXS!mrl)*N^Hr!n)G{X>d+^k$e9_#`R=wZICazqW2yr7 zO@LPEB+M8zwUGJz5`drvB?kbmqyXLa;e8qM)NsAz)TX4H)222`EUPKe_hut4GRGmC z_gAyEVurqrbwMpN)OZrOHtZqZ^bq?arK8(p$1*}D(M7eU$TQ=%hJX0dY@5sW*R12tr##i5!FD@>ab%{4k zNWdGtvlMpEzq~!WJc-*+>7jG7yP* z3G!MqGrGL{ScdajckgU;>aTebiVkDJS$IVFxi@J)<93rhHHEF2(3=0a2uj{<67VbB zkHWfX-&-z-n8xuMgEzVr9n4>zi}=t)ng$nlQ~pU>zf%rdz+L6P|FJS0oNz;cGr!h@ zchV7XMT;N|oeu;M_~=DAj(P-I#hRUd41IJjI777$Uc75EC)(^L8@Y(_xzW_uZa(mK z0x=$L7WX@Fk!hl^r;EQBMvQei(%;rTHVAoi>zP&ZNqkH-boySdYy=7priSaZ9LF#C zvLXN#@1+*aSqh zEp3EYj+R+w1Ia|4QmSFO?~G}awKarSwVAr;fTZ{D*p}5%>!3I7SI^T4wikyeK$z@0 zlhO;xJZF|=Yz>`M!8mOR0=ViB!cdsV&RpkgzEfy0rM8lxN%Eh0)*g+;1k<2gF*7`F zJR%fnIKP?S5VX(TT`<4HIVu!FbN91fdTDjp%_-YcV{Y*;rW!Tq`yjy;efgV(cWi?v zKYUjeB6v|5aR>mSB;_NpE8)icArlBlUS#-6Wz_c5Bj&6)63q|^(5`joPryQ z`2)Fq*oYr^VOlMp-Y&y5u~k*Z<>Wvh!qDO(#4I>9C~|UjJL>lyehg-5WS0BTnH7)E ze%}yUnF_y-i8XM~co~&m7#}#npnEf8?bjq~Zd>!yGGcC_Gs+-psfWSgM@cw_s1$L@ z^cFgDKNCWf2ITbHPlA>1U*LS8DvKEaH7=b7Ie%fCL*He&pp)2_k>rggHKSmvkOSEa z$D6ST)pt!}EW)5~I)aqzJ_hSTnGrD*hxR@XR%Dhv?5d8jlWu-w->cIoss=k%F)Udj zeAT{ffEPt(I)HBwh~kr#YQQ=4mV}e4|B-W7fWT#J_GE)U$h!Nfif&?T&8cMIL0z{a ziMvLWFm6KZP2^;wJe~}prwd}wmt8}07PuOrKuwp+0-32#%iXA)m1!>#Nx*PQf|c6j^F?e)gfP!g7>(QWiA|r=Z3+&L4l@A|- zwdB#!gai=k9icuTb8rVKRtlmA>Ug2i<?R_;0AYWT&=07&kv0Bk^4@G^-zkFI+mhq+1X0(1xnAt1r7DAG=$$WxI>*#8{ zcD~4DBC^E5_rfcj05{($+%STz0b2}$TMnz51+b5Zi-71ZWERAxnQwIY{*%%X9o8_~ z`-{CSUwMhFX=9@3ghm370?m;4mx>Uw93*o*BG&W}3mhRM~>){E%--bw}E zALc^q(F(<~ppTZl9eh8IfzRxm{?U zEA1_AW@QTr-zzd>9qpZeymUyS4li-sdd#g)727&Wi6I{ckq#CHm;J6_q&Z$4GMpM1Dx&*qRv= z(?_G3=cr*1w8Pw;uDe}vx?LNESjPG}64Id+;U+)?>ymqzK1}2QUtxg!p<~rUKlQoh zTyH7rYpI*4ujs`~WvblEsdem*{Te@JdOW21B=gfImZaieh|$<5rfDx0J+Z;+(L}~NF9;36pU6uAO&UzoUXq1ZP$PINElmpVp#}#3* z41MM+TRas-SGZd`%D6Mux;)?B-c16{@;z$JR{I^opYs#O(2(fhy&FJ2F6G;Dy!44_ zdYJih5*VNHKpd>YW-m)k(ueYEn=o*Jw@)FH%sh&X@CQaKXh9e#+ACi;!B$y1j9r@bp--GxIked0lNUBb632 zsR2LQtu6kNOox|=>O?&IP5HrG6RI~nWQ&GfKlL)Ol9(JM>|n#xIX}G3d_i9QY>rIh zAzE>O^$Qt=AbT7lU8&{Gjwn~*(#Ll+e1y|~%zJ`O*H(?Hm_VVjHJme^dh&Ogg$xNU zaf>_DWHU_#@vuwX)kQRiSN3Z!XCPMrLzf#GN&j#~C&IP5Wn&y~(ksi~ncr5jA;MBq zr@gEbx<1ml^_tDeIJgo=+t#B-){M;6l0|wn|1MoJTgGNubt&LgtIb}Cm8(W} zsMO50(I0X=1Q~|E;>8PyKxm>gK>dcq1!k49p|cBf!YdUW+pY>13q2d!mgM{OPfAo5 zJ`21h?X4?Vj4JG+GHr(YqgCu4_j0vxY}I4hb0~99v9V2h)y;9!s~LMZiE3|dCVHyHmJG<6|8+<7ILRp8idr_qa@b|iR?c*9d;rCM<8)Mf1NezamU84 zFu!T`N4L(8|%X?|Al>f3&usI~rQ$Hb@TF7N^ z<4V09ad#rN_2jeZZ(K{vQ-_9C{lK;8R`;kpD;BT$6y(b->-7EM0uQ1ibE%<5vjujf`x*;hpBo(S;@=7l05x_ve92I1+x0u z8cUX7;yXx3Y*A5@XREoS0-v(8qzDno?~qQ%+%ux1-hK4aa?<433ZXS6Ap_87Ktu-3 z4y!5!Ob2J6({ycyfz0)R(`^?kqx8$Wr7a>~om1 zCIZvhUBC_&<59;}LxBM@0|*ek=r@cOkivZz%BszN{5Uu3Axdo4wImQWemL?)8VJ@D z@WcavVkNZDAFw2l1Tn{Shn`~~_PJd7jjh!-CD+Rsy7Mp>hcCz~Zxt;Uhaa1`lxcJp z35icIl~{L#YTvQ*>kiuW3UQk~^RwqxHIRc?=BN^w8RMJ1%+puwZ|c9$Y!0jW?n9h>^Api8_Yc)kgjng zd~>KZb`+Y{E&mc(kYl%5>qsd_m&##c{Y~Rm6>UsC zfky}(I4|=>Ke0cXuCd{YN^&*sF&vqmPyDFoXZtIydC?Q6fZzZ<$-@0tI+okqnEnW2 z6Y7_!Br!A4+T1l>R&Y+Ailf1CPK?(aZ>LPV@t&p}z68wg3hSINkpC=9r2ogl1T>hW zbx~1bv9h+Xcd@Z`ursqTwY4)aFtM?;HM2A~HaE4lwKg*{#{TzN3v6?9Gc&WW90sr9 zOZN^!;Ay|r=Vkb-Qc_q9lJGCq;ly`|8TN&Cuh5+C$) zRmmS+k3ErZJ&oQ-b}07)If@gdQ-*zd)Tiobhp6td{kI5R89cEMN4q3<21Y+2G3>G# zlh^Rqc|Sra^-n-F$I%g>75eSphg)md)XP1!?hHuGt=%LIkziCjF7)viAY)?Y1_gV5 zV>S~wXmB%M+QCXwP%kO*eJA|$7+wTz4X=}}`K}{$ZXjtG?jg$L&cE~SU;%^fna<}8 zNkC3Ud`N4S#W=)AT>*eKd7R{VBzO0VXpHz#78;7XaQ>gXM_vuEC?cEP%d(@zGa<*Ure3{v$YU5@1BHgU*@bW!+NEi#_L^Woow6n zsCwhCLN5+#R|aCAPET4XxSo%FC|tCM@7DKk7>IBL7rdTjjJR81SDPz+Yoq1ubyB6B zX}>dN;P{k^hG|_*4nY6~EYQf66Y%WEbTQ}XogB0Qx=tY(#or;#twA%@S3ZO{P2y(6 zD%AC%Bm_(frgx^FuDPhNOyWFvcJj<8?`cfkClmVlsAj1Ri>+d0tSa&gDy;;*`g@xh zF-@jQA5-^vKqnb~=8X)m9`$8}0Ka}W*#Q6Il7%YHA9v6;>srt|K|vr{4O?AdrjeG* zsgAAvF-U=*I1_iBnG$~c;S#2$5Jirntn>I}7QgT*pI3>-#quG9Bb+X_2hHcnqiPfl znF%wwRO;^{lKo`;C9qu-j`B=m^}k_2M9rAo_G?oGLmPZU2CqZ-9IvKX429MZsALe4 zpYG!cT8z4*DpoL^_!^G*p0D3u9BsKZZLo^z@UdFyUDA4o#3h7mXTflf?CA`udYM0; zNKN15+FudI-vM!<@9FG} z=b^B4q0#Qva`qGGxIn9GI^%PAQrb}xvpeWz6-wpm_t8r|s=klw@Xwzu6DMb~LN?BQzGcs>VZgY@SL8SwfS3@r5j?@(B&ourWeQ+w*tMxnUq( z>_|WaDC&h1)Ch34Ru+zy^cAD>Kr7{Q&Q|2Hfg{hpo*%A8aJ>@aOBMAm-aj|O^XKBa z&;@=(9@gZk`Xk>mi4?C?8J#CQp}O?qifinus}VI3d)dH)J|M1kOOg8qGC(uE6Q&qa zkahkh=rd4h)v>e;h2;NXiG4J2sFbtpBZ&vXfhE4pEdhe;OX7Eo$T-5G7r(i_-z$5J ztVerWQP6m)r50k|>O}DbzqZ-ygPEPal&d*q*%Nd53C~?E4Dz97@V-S2oB3i$e4k>7 zYNHE9?+@EixPHsb9`1PSyr@S#DD-tmk+B+|JL9F zOQ^h0Bl<_sBvtzHL6#k<-R#uv(bh+F$N!XKKyMSUNU}b zYpl&zQCx0ey&^wdXl%IBJh89ZX+jowR(q#>=h5DX_f3djwZqcjh`SLcntIW2$U?&= zm%Jxtxkkw9O{*^)3aqfpTF!Vr?BBR?_tR4>WoE^v$u;TY5GcShBTzuI)ob&ajP2UZ z@*}B5^E~O8m#r>G$i z)IPn>$HI3Gh%94dV*y;&9PLk|hrjk89VhEP^7CKYarzK(rp5=pAr-xZt>!q9jz1kJvA=zvaW-U6W%zi*tVt9?EieycnF$#_{1g&%yqMRk&8hnDkK_iY_H8`tn*QFBclm0ZG|S*- z)sJOxDM10=29`e6Rjp}mM-zM*YbdF~5IIc7al*q0q@f>0305b(g?TwcX2*pAEd2K= zjNU7>^tudIlpFUg{1l`&`T4kH`$b27K=O9}z?PR<3W1REL_|KTuQU1uQ$8o0-e`!M zx@@Ca`_PVf&JffpElr0fw}otdvw^#+OBS<#q8v&M<+bP)rhontfBt3}?FWRGVRrcZ z52)^k;v!oyjS8*|t#2_oMNfqxMnh2hSt;b{Abt7j2N^4#m10JHA5#J}F+0!Jt~$#B zPgB>TmoE|16OJT#tB_9dWGF}r+TSMH|Ii7AOZCz&3bc(;UUO3L; z@S!7`3nO7IVJC`0FOw5|ib5r~hYc_NS+8$gu9fmrTB`J2&6@VRXG=9<%as9C6xG@~ zBM?7v)N-3RJH*%vHwfBK5;p@4u5-?-cz+(Dw4ZpvFyR>MyEPDu%^d^&#_$-^FaX94 z>cp}nqYbA@ZOWJPzx>pukZ^8g=o+=gTLV>#ZqG1gc=@iB2V8h+6}wV>xAJ=uX$~5a z;@fbAa~X>^eE9seZ)$eVah!FQYJ-YPM>pk-?)kIzX=Sfc?TKjsFVC*=@4PnTv zP}(Zv5AP5N0^WB9_t9By#r75S2=L{FH63yXZ}sW)3%=i|YK}8`;5fBbo z$k`TYKARsuU+>Rn*A$)IuPwM_)?(6;_)^kDG^jr`%zRS~&a}m3OE#Z$aY{e$9%E+S zE7Go+d?5ynvtLe*2z=2Ol~;j^$J98X%u!a~E)XK{OW%Z7&XO#TQuHMB#qoX8XoL2z zRdB1MXYpxSMsWQj7&&=*1J4vZ>u6?_2i1DBL+P#^WZNPJ(t*nm!^1A0<=p z5UDi-{NtL6tQx|)+VOfXeiv?sFk-^V|1nVBGC7eW_7O@YR8*jU-jTb)(R_iP3Bjyc zFK?6W;APTI4qtM*Wj#Nyg)^pvtrfqz9P>WpP9HnP54~<(J2t~+Bfdt>wq6Q{^^JhH zR_hM^9Q|OW@_c81ephncF)_NQ?tMeTqL#L@;g(235<40(+v>_?{le|f#e~=3uSrZ! ztY}_i62U$sJ}u&QHd07T^gVdO*P*cCqV{sN{q08A>0Opd>R9aiywSaAg@LnZhG+BXZ8&!N z0)iWF3}2=JUQ^O3YDZ5A#Gd+Tg7ze-Z5O}FB%8ogVn9zF7^ZqcBZatqqo3JkcH>)j zvUPnr7h?M?c*MQ(F7QeqFSCE!IG-soaHjX^w0?S3h}+Q?OPj5TB}`FUBlBKZ@d;~} ziic9?+tJRiHYj)Y_gf8HAqDdYt?*$p7iSj5O{Yr&vNi;C5Jn_DY%JN#_z<7ul%!fz znx7s@Dr2N<9!vxjx-%rkLQ@p#M_XF+His6d3GwyoC-_D#N#v~&M--SUu6OK zLVf^vtV{N!%vu*cxi40%{fgG2{h0ACwYW>IEbFGqBT(;G%BPVG@o=!Mp4`LvQTS^zZo)~ZB%M_3&Ys9};0g((=Zef3 zMO5B{IH(#OZ8pb-w1OEchWp&zyv(o56O(aLTH9gP_+xk>@T($s_p98@-lU^69ok{& z8=Om~3k~bu{4mTGyds?BqKqoI*DhcK{Zc!y4JWeeSa*>~#x=wB^-`+g^xuDAISUHh zu7G%S<9t$s~P$8yNsKsW{2~Qg2uCgG?KIH8BrpN z0m10_&$cPwq;sBkGZ3g%WmA26aZsNxzmAK;UlUBw#XpPmGY++0K^RT ze-4d`D&Mi2jY{k{$D4twpgp{WW z8DgyU%Y1!bneT7Q6jg0fX87oS5l){jP`#a#uYJgy5{qLs9OL;XIPLQS`;d(X#iYwm z4`c2`(vUL}Rv;=3b^z*y6+w*(Hnsq`#r}k-Y5L}XqsRDkTBCj?He;xI&RkInp(uRr zlNIMPjjpps3Izn!%B7zBTDs@MTK9Y!4Y5}cGPV0q)sM=7ShR|Z(x0kT)|XCyD65Ve z7r0b7^`ftFeDQ8d7{T`>o~sR{EC57+WFK+f(Tni-?E85A=W`DZ({3~-Ir_1egG+m6 zvhFGO8VtyZK=>-!ECx%t63b+9o=Na=97OJN=75XaoJcq~Eyw%elv0(@M4_>)thsG} z3=UQ*1;AJHcS)0oqKHA?`p>xaU=$_bmQ2m0#^o`0lB_$oM^+JC)BIWE&3m4jDB1%S zK5nCTMMsHwIB73_TzeQk$vOUTqc8GZyC)*?%3S%~-CdrsK+)xCi1{BXy>FAq!;nW@ z*wz|Q|12u3|Hq;N_*N&7S3yo8VP|V)V{Kt#Wo%(@Yhz`JjrDq6<9`}xn1bcZbgj(H zY^=#d54*`vaa@~j3 zYRZs#%I?-=uIEI%Ve4<4dixeF3+!jtI$>u=lb6p+)V|fL?t8jR49p)tJ@P2CJu=jX zB{~Kdfp8gN?u3Uv|PA4GRk$w>H5`GJaKq_$qHNGmkMLP3QVgcckV}9Bs4f=eX zY7JJG7EaaLPH#?d37R)q)q4iQ%MT4M=|(bw(}en|-Zd$C#3H%36S5^lbK4&u51h2ES-IqCvEFH&<-o zVUNZlhAW*&#d?G-mBYAk@UGRi5&8AI!-DeR)p7UP{5lyHxv&_UcTewKGKn@Rc_vB> z5#^PRAfF^JK7Jr-I+RvGGF}bwcVdm-00Pnrrjeda5rhpxAaw(&AbzmFLmbbt97aen z#*9YW$<3cD8Di?YvyxCt-uytZ;>xc29qmD7Wz^$rs?P0TuObxAc8;;J*mJg*ly6oO zdZ!`oXGcVjo*KSf<_{+bbLZ9Qhg4QW%8iS7&yy$R8GQT4;4PVJAB;LnLCC!#-2sXj`EF3?Qc zTDOnbF7wuDm6+F&dC|KQhW7{32y#zwI~9^gK?JA^M#3qok|No>75ZugSq1QIe%}%h zz}vDhG*>58Yj>xLizcM1Nt)Fg5*D8R*o}q*(@VC;nd^wz>T0&AX0yBej%_t@I`!u4 z-EXD*iLdYc>159Xj|uw~=}Y_XhHDwhKD>2ODg6aghyeTSLVnBvzWj(Nl8buwGRoz= z%bbzgAGI`MO7LARo}Z(-B0AK}3H735wZO`UXU08$ZS91gdM{gUl-F#!TITMVwsg9* zEM2I}bR6PKSwzTLK5zIh)ls9Bw-Wdg!D;N3kZuH$CS5+vqNI`}G!iM7os@4c;&Z4K zLeB;!Y?3B^qZ|JDO)

-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

J5=$m~DTl_B_+jwB0zU>F}<6)|Bck1^)2^V1M z{y;J9<6d6P(}aSObxQ5)|9Pl?w!A3Tx;!_FXF0=H$crL8rc}JNi&Z`Rw(s;dIqH~| zUv!%6XzwB_R&#FV)xb5v<}ZC_&A0V-+v15m@=S!1vXPvi9he9m(t)7K?4Ji!HSH*J zg*iVp0dT9;4~^N_>iQ&lB*ZL>e8U%5e!S&f>YWeA(v~NZ2m^i_x(PEpcPBjti=PRe z^JcZ8hmS)fW+JC?xW|yyUToUBzR-V{Y5oVKcq*Ets8&ekXi)8%J_m#lhW-MaAUr+b!88z?VPX?+wtNO$?wd&LL(2E>i#-Fi<)o=yh|MFm0x42R5% ztWyG`0Hy%N#YlmRX6=an7G;{dEAnrR;H5&>Lf(`Bi0DKwq z*dEdhUYMc-X}L+ktDVf-cb;laf5ZO7ZfHpH^Q^%+OUU^0FFQwF((YgXASh_iwHFT< zr7;?-d|}-CXNAv8-k26%9q-qn6`RqTWQ$hu8OGblc8=_s(L?UXE#NcRHULobg~;Xu zUsZxoU0LDoL#MB&M2}%SgO*+6og8}ko%{XW0-qH#hte5zx1&^;!6KR4t=BRO+EKNK zjl>TqBh+g*<)m7Qy6?LTawKSv;+aRZ2EZbvR6=FWxb01NyKjq8;VH^&phyI(4nqEh zf90Vs+cS$$eQ&3u1~Q>`Q{AhhqvHw(A#ckC{8`}%jE}n^fTKHA|^5q@rArV*<#^( zN$hMb4|-(dclKi?e~2{d95%WIz98ZvgS2-fL5I*3#Jd0say;4mx>O~^FV?JOdHNx1iqbuh00=|T_+TE zdy2x}m`)G{;^ML=!skkBdZa@l#4Ts38{Y|Fpyq8KzV8pUP5EwA_keGy9~28mkm1oV zO81^;(mUb}@e)Tc%sv_@Bdb@deu`?XUZ=W9TYfPn;|?>tAHZiU0}%g=y7AUXn4 zfF&(HlfyvB!1uto=GVp<&bEuFF+%~hhD0yC4EgxG8;x|{tNJ1PPBMQ3P;%g}S&)AM zWi-RVzy2NNZJ3HkWJd)cvaN`f5ME7FwzbC-t$;TR=&wkiQYF%Vs}mG3@0!Ga5hy>t zpNq~4idY=2t&NOL9BiE}t?aC=>}}1g?JaC<-T{=<|52y$o|(YIwexT_ceXTBQ8h6) z_9hlo7G*0{pai?_ix)8P0SEi(KI+C=!fl$Q5p+9~=z!AT{3` z_l=4&QX#PpAsKQ;^6kK)70HFMOzEh^V^nkv92@+YEmVau%0G*R8h%oCu7=9;d&^ux zKU&{s{B7ba;h>o1JxP3V)^P{1t#|+0c;SC^eaM4)Kh<+)%>VpBS9t$%%k}GeeU1f3 zM{)EU6FH7?Brt?{)P(vG02lza0|XLWYF7|EkYEkBHHHdSS{bBUEZMy}c=;|s;7t8? z+30(gT86vMW<9KqHk&UrpYVDS{JxPmf55lIIvYsUXNbQU2y(;=Bve|0DTYk06jw#O87IGnFspixp4(OvbTgWzq@=#ChpRrGOg&eN#vQ2uJ6lIaz+I+F=Khl5TB`+h6!}1{a|z z+7a5$_p<{wzxjikle=D+h5^dDs}MDu94Q zN(&#%KfA9M-+Ys;v$Du?6o1YklDXWRkxn8y@xvS)MbSE_DHF}pE99F7A_T4&sgQwQ zZ_EKE!mD)jfCvO(JL{%(*Oclz4Xs6qTlVoXSN<`sS(KSUzQJ+FtAWNKRXyScH3!Ri zFaO)!B+lVJS-r!ndhsxtc|Ubao=&9^V@KnBG;4~9Htz12w7@Oyt0 zdxO(pghhJcxB!ZUazGKy`n&z_2YH}f_dB`q88&?#C=I``k8-U`k1^0a>TtSQc%8B3 zt~Y)2`Zt;W=)&)7ud#Np7q+vs?QVhbjd4Z{G^*&4s?BtB-@!m5K0_R z;C1&!ZvsOnaLH8~)=0=-bC%VKzFeAlF-4Pic{DB=(s-JweGaYDPCsZj4=+Q^6qa;g zGuHH#(<>6x`xZL(4sS z(^4VD*ahz{FbVsOubqP?o)M0_75ALP01_lLC_fMeCs-@;%Ea!?kHP4Ewcgyi{Bo`E z6-%#eMD?cz?nhv(bD~ttcKo0rP z;uZ<=@G#Q@Kgtm%9XGpqw!v9EKwL-sC&9;nP3m&#y}s#m&9r(vSG6=9I8LOJEd05?q;d;C18$LC9nyRPg-17+i3Y@N8t* z*?BXy+#1C^@kyjAm8zP{RfEp_;YobYF479yVVoej$^%3e3EsAz2utRb+q&;96ENgGGXv7BG;6!}#Nvt*a&?_hsC0OoeV2dWFTbo%nU0ltS<;lA< zY1piL-f%7>+n$PCT|H~#oBnHgS;shVWQlQTJ3^WN1y(9KPf~@sZ?;)udJ(P9-O?;H zfA6$~#1z@=G{p*EJZWi49rL;8aCU+g(jafo0L|p%U?Jd;^ah7iV$#Gqg-?WN0h6H@u3;H1(4iZtoNLK_VqAVcw z*OK~8OEVF@f7i#1H7H{H7GK0G?9&c8E+eqsABXPW%TLJ>=!wt_8SdXt(dihHIh;)Sg+Fv7pm zzW%-R(!gYlx0y-GZad%}vxj(7cUAMj?ev+>%~%izP9C&0xvaRlyq=bgvumJIEE4a( zA;Mvw45ukw2LyVy4$9$_G*+Zd2Y(^X^dY(vkhDh)S&BdW3IM!JwJ-2v&wdEV2n;y9 zd|AwvcW&YTdgt}bF>=GAlK9$KetscDL+H+D@nUcxU_kESq27U=;2Auasw1aN0D6i` z92|RplcDP?@O_xFyKPj}200k#(3tn0muj5OhsK-aG1?HXB84o7j_N;(<35Jqs~RGF zaW3tq04RzCU>xkH#koDG8J^_sW&1r5_Y!Wz%_z|ol1$8URCZ$ifI*vAN-JubZ+Aid z>MAU_@;zBVCXSzwG`!R)^?{W1etLEdc}#ux-tpuEoG5({LCieQ(EAi&yO-(=hk_Nr zf(*z~lqs;bQ#VdgkXyD~Z>-!&Sb#Wj=wFDU!R(g(jPc~nJMq?OSPf|#4A--(ljRoV zuU6Wot}H`0Ki*w$`MERHxhP2-M1k{FA%d_pbaNOAnu(p+6FN7GS%iDL6jC!Lp(l6| zzIw-|*52ovjHkc7LlFj^9lmP1CGtRTx$zO!Pu{wdZa!uUoKa!P;A6ch$sJ31#og&g zZw0p+o+97%33Y1LN>1Jhr*f5{5DNYC)-jtVF#j@RsYg}Lw5jy3VD3-I;&~~#{yZiy zAkQmDl^?@woa3{Y38XGh^*2IkKEmx#d-^OH*|mb7b7#Wp*|aAM=CR_0c=%7nNkMq2 z%nr=xn*#Qs$C$7T1$k2tjs)=p%F5fH_Id5uj>N=G>8y-5!QfA_NumH^ejl{s_1fc{F z3_smZ(@9uwU(SjdEM~dEqIDX8WYZV1Lh-4N|8h|)y!f7%7q&<_Ej61i9JRS2)k|D6YrrHu`R z>lQQmVFCNUWAXlHJl)d_=6^e7V61T=jr-wbT>~hEa-K7{I?Z(J2zYB zk}ZpE`j`CS-e~1=!WE@h96eG039R{#sG^`vDeS@53|`?X0PL+FB;=ncG5s;(;PYxE z)VmsbcX7k=Wk2Y-z3pW{414Q`qe+II%Y!&kHjj#A#xDd!W=}3L0vcLDk|vTm-=~L_ z6Dxz&cK58rVuYKg+ztfVS$nb*&Tn~oCIs#c$F<+GH(2hb`=2QP;zw*q!{a~B1FIw{ z3+5Te(s6YchpDdhC|dm=Ay*Jn;>;^ z_(}IyJ`@7&j-3}XW?Jw}HBX+}=o)2aW1$iALq!GI_w*_M@c--|qGCtL*ia}0npzp{Q=-bB0XG9*Xo3px*h!J;(r)`G7 zcjhwui^Y9TM83@$KoLk~YdbFAL!dbN*K%<&hpj{9`sw8{SHAFCUZISo;HlM~?)1Hl z9QmYmg|nRR%K5&ckG{7}iR8B%?+ao=0>Vz0-x`HiR!v^O_qUy=-M_XI0p;Zn+$jwl z$4x|NLu{_WDDG;3i|DKVZb2|Tx14~^(B>8wqldlg0h@e{E`zH!wr^UD$B(9VI{LB& zl(+)?R`MDnB3LIcZH_0d?D*OHvzs=&Xq?Fwk-cq?4jI@ZJDPcTMsKb9>w#{(;$o%~ z=C3pj^T@OtHJU0ZoLVXtIqoKObUu z6Le;GR-u%y7WIjGoY2bYR?O~omeO70O_;5|lnFl3s+|WprywqvlIb!NuqiEA9f<5; zOMfeF2=JbNhJU{ZkFT)}9e@=s!w(Bih#M%KomFF3oR=#|z0jdOP8B^>{!*oj=GNSh zsnh=AnZgrj8{XAi>#pZNaF=$8C?1Q@q!*a*qGQ|P*WUsYxM&t2aQTGl>*mNq319Ou z=h_V|#Yo zX2318i3iCE)Kj#9?_HEcOn%N_rtmGp#22mONg300cCr3K|V#2`l z&OeRFk9Q6Tnvi7>$q;@l@_~_F)Np8;D;AqNvAoine|&ikhAki2=wgjK6>B&p{2JH& zvOnEG2f_}7%@fyKP0qw3=z%<&HFP$BDx@h2%a|0Zz z4G?QoSfee*4E~h zR(clJAzBqdV*&mE53#oZpzUNCDK22=o-6Z3YUsC`z8B+_47;LzD}ZN5Q!St$(qMXL z+tu;%=dYKw-yKEe-q6{!7&KPW=6$`FbOn~rfL}gj{&e&WNZq=PV?kU=j9fT zXs9f;3y9E?!=b5p_?#<5y$gWV%(r)90L00+^E0c9GL6qODkx>!OTsWw21MousK~s` zE@ET%4UE^4TQoK{bv-Y8L0^XN6Y&QXv1^EA-{w**o4fzKJ{Y${O7RNt6 z9d#OQSZVFcQAOYADCe_8d4sA|qzcuUSHO{Pobf7JhF>k^v-?TWM}bmh)39Co_Ry7@ zjF(koD>=t^0vCpZ|3ihjm)hyY53}AB?1s+?2IBAu0jk@o={hcB|9tp-v7Wehzsl%m zj2#R7>20&|k|c;5;jxxzursH(^BhnGrEWoal7ji&Y2f>rwO(RWEU2rnz&9pT%_~lU z!?uVa_M10e@dG-F8UUN3+ex;wh8zum;g=@z52O~X^A60(9>bZzxy*)EC62JfkRrti znWeKnzC3(nFT%`}<4B+3Wrk83a)Jxpa z$zM!b)%L$~j(uK86+Y1F?&Kfke6$lduQtPIW2!KObVQLVxFz_0x=0N#j=zoHIz8n6 zYR{|?Lqgzs9fX)`ss_u2&nGtMVk+Tu)>bqrDp$OzJr_wRKG)0(*45EZHzv6`mI9@TcLWr zYlii3#d<%!rybxUmR?$_d7Evgik{mt@%N?sPlsgaj#!=cxdTE75e+fWe!YL)b?RNE zlnIRo-zyMzL7T`TPdvyoYEHlSS38V1$#bZtq^yv0OVx4Da*Nz6ms7O~Mu`sY^Pg86 z6Xp-yF4pb|FnPm+;@sAx`wfHDY%MZIo?rPv*h6w80zo~n7eoKdKzA#Gn>vElmme99R!m-K{Ndvyet~Z(v!a?=%x05R% zhQeY~#9zG-B@9;afm2fijc%F*?yWgVPr-vA%UkTP6k-PhHjdnl22%x{g15o>o4@I+ z+tKQIKYi^9*MjQJWS3uZC|Q=>{ci`F>56Y|zw4aK3WZBvsoc~7Jh$LMt3k&kI(w}h zgg~M;Q9=M9z}{r`uY=|&p(ebcAnJX9a~UX-_ngvCnQlm)2YfBw#k8ZLg0?)OKiNOf zawbF`>)*0USG$XbN;Qq)-^Gc8;d1tQD*5;KS^^$aMf4KMcOa~;vxf)tb@oSOm5c&* zAc3G8*<)FoDD>z%0JpnngmoYq8RKH9e`&YNUZr0~2g(7Xyh9sxJ??a;c9Q^&Qs%J0 zs61?V83i$G2=UkZdGMkUYqYTolG`(s@`;^)llsFP7Nd*mQ4iqngK3GsS&5$6v*yoA z>W7=eIf4g~j1Is7T4j+gXI-j?#Z?)#5y`+4;IBGPWMedfRMW4F(%_j8*G-lYQfV24tBQKrt zTVB4Pq`R)Q3^6IMii7kjdf;EmH*a8T4u0{9&>41r4}d{0s2Gj0l^a|tHQJ!3hC~+x z9ug7zaiZ-MXAf=V zwY3r?30C)~DOg|gwF_$yg{Zj)>goRd2stspYmN~XVs{NZe^+PY++d9DCz(eJRU~;G zO5HSxh}lB^MV%X4)oEBrmM|xaZYdMPYc25Ztr=t@RdmqU$QcXVvUzph?Aj{_?|f}3 zzevtw-S0`g^}Mi@JK4+({|(AJB#t0eW?uyFuk6r(7}}$eD9&-I9TUO1;W;Q4}s3Z05g0r?k@P(=-)>YfdS`A+c8uR=4 zeSDl6Ry?6nwSb!^%&?avU0)14@ zat?+w(k3Z^n3I%*yWte^z&42_qF@S)J#B!u@Xu_8gu=*i^ujD(c$^)Zh--EjPJ^wh zYnzpfhydcom5Ym3(pn2YZ3LgYKiG8GON-!BzZwLfcY^u=`r;%&ajhiI5T(JP(tkli zuq|)v#3YGw<;lNj!PDkJ^Gl15t`X8hENyaD=3I?3u!g*Yz>>fDL%oGv5{sK{ zmB7ia_nk6ena2b2Uf|bf;16*-exEm2o(dZ#cB^xnnUcNgA^7dgzOjRJ?15JXFG}}W znAA_8#No+3$ZyS-F!pvK%r7DGguKlyCJ6fYLBD^RB@+V3OunxStL;q0i-qd5-)vNq zKW_ghxohm~>}=`m1U=0sL;YW&Z$m>vl-2d?^D*5jqq>-`(0GGBDlvQ@iDu3XIe;3BDy zi0LZ=dxdKj@$ z@H-yTczZZ}PEr==XczRGtSX-Wx0yrS@B9QIS^5J*K!P`NmAt(w)o+%6bix4i*3#{I zANHP00>0+J>gKZW0iQp7VoSQ_1DjgvaCA4*dc*l9S>%Y`>*!>{!>1`-z zsXcs-6+7A4sn))~C#xrX8_S477HGYh7SGc7oPIg%oAg{$v(=yz<$HHs_o%A4f76&d zQvw!CRtr=&p6)yQMHbS3O}9DJ_#q7W?nYfCtxK;?qsR9h{U7=}VR)`x{eZaI)>oF1 zxCzSVD|8mi@k74;DUC^A6R}trS5dVU3t?RZE1^Ypr4a3u>*pk+TxKun2RT3g#8rrT z@vP(`nwb49(4H^{{Wu~{7@=Pih#37;^LItZw8A}GGB`K>Uh(0of}6HTB17+1uPJNK zsF;qfO=cVXC_S>3;n$Irw7uo98{&x`+6235E3pg?@FGKVK4e}7urEdEKA!N@q>Jf3D8JS+w ze&cFA<&U2^lQf7%jP~!)q}57xAX#q@mQCZqyUQTqQb^bjr3&m+%TF9wox78R_HK(N zZP*hG{2!fM_gAtjS0+72OI@@xDtM$aW|cl6jPo1fjDXKc&}H}OoksO#1iNa`mGvkN zY}iE8T+}006z_lBSKZdAR9OeqG`{h7^sP8YU>W~t9KK#Y_HEl2d zzcc9I&ADesvdeIsa;3Z1y9b#+ajX@rMz3TAdhO2_a)(t0XJEj*?pPl~9+UXA!7{N` z+Eii;X|auBV*)nx6Q&LtGwrkd;ybE*Nh*l!7417TKLOdA4j|1d)G zO|pI{OCdlg1B5au#XHu!wyt^s;a!D#z+;HV<%-|EDbFXjfmZRQE)PrVBXv0d>$6t4 z5S^%DEWuef@*79wBD1QD9bZfF4@{T{Z8=zUL5s)NIJha~0N~PB(PjE;r0zsxP48Zu zVCbsM!tnUXJTaF@7$?9f;A&i$q)JS$_sub?N>-J2jra+v%9_$aqYcQ}tP&4L7%=^2jY zIvf#p)++_^)^qCSFo9a4l*G@IxKl0cUe=Ak?-_;@2$Bgj8app>PmL!FjaSPzS}G51 z#N7MzXI|tvpaI}q7xbbePK{>!_dw9lSAA>g(2rH*3U?ysMVvLNxP2F0cXOt8t8G(A zif4{XMcGcx$S6%PAX2Qqxz#>7#9c6|jM?20kiwD3B9j1D7QTiPf z8ncWj@luOO<;s0BhWMZI0vxl~|B@F_-@kTk>ZO?2oot*P92{-!-ghai49%^~{wJjH zpGk%P`vK>@vDw-9e#hvI+3HuOFatk^Y86YQW7g2DiuA1S#l_oEW>?Fda2hMqqF}8V z)bZlb>VezwL!ng%;Je6 zHlcT9lF1*MJBUErTxgecW`20M?`0uas5~()vA9$EY0UE2#$-AR^mb@jslDY~Bw53`Rgn7vLXVcGqQiD&^iItliLk z{j$*AE{@Sx@zxk!jLYd=6B(jmd1&Iqh`=X1Ubm;&1nkh2f;R87tRsYRqU8QSh{^iW z9p&!dI2z_?7i^vq)q>R1edCGX-Jf2Xms{L6eAW@VSdU_{Wm&fJKtN#BlsF{lN zxfhF#aaM4^6a_DLX zMr?jtdT#@{>_+RB1>~dzAWGK)wc@71abaz%SpkX+pb6*jb^NkI((*N3y@V&bm)ZA$ z?A4o708iX>?&D6xX7hQjKM}&Ur_&TB)2&4_m7@k;M5Ef@UB{BEgL;eG>vL5-eG~Lj zt8rnN2(sED5&!`GhoJ-j0D|o@Mj0=Zr<5Mo zB+A1gJ@NT?iW0R8YLh-JgI-L1_y-*Lp@maj^Y_<1kCr5RXHQ*s3&yn7(yd(uR(;M? z6~4B~b`teZzzFga$M^4Ft31}v7*w_lF;n%|jfqJUkDpLEOTM;u`B_%j?f)Rf+UBYB za}h1hBBg@pmin9;V$DkwZR<-Fdlbz3sO6>doWU5N?P!AhkVY5N%`AUnC0v7rcEJ6* z7f$+qy34q=;T$5s7<|aZ_$hCh-a9 zXeZsl%kQOS;&hW(LxCO}$}fA6&sIhgElHzeSgK%9f901;R;IBl0Ne}%R@ng)w-c(k z1K-TOE=9D<59Co`jK-7J`?En7b_~dZAHzPeuQj1J#xxL=E9Pb9Z|eOuweqsE@OD(? zr|!Db+fT5}=AyJ6bMYEE*5{)-E2q4R7+Fv@+23>bSVsT(DKq`aHV%?ynaw@m9sSFs zl14!9bZ>Cyl7ghpG9pdR^AxHw1$KosRh0}g*9n?_y}ywJsZ9MCn{ z$JovuI%Xne)GpCqnaQ~s%1HDdh~v7fLTjC(+lw+}e1tAo$XRK{6e4v>rG{(BNPu`< zq$fWV&3vSy*JF|pQSFI@-B!PcbsBix`t!{kq-VvZ>(FKGHys}1C%+;0CA!!e34B2x zTI@?RX4do_jNX@q&6=|$YL02!w^a_>85PC&%@)g?!OZ+8{ZQ>2MGX2hx$u}E6MR-Z znUv!Xu8+pq#AWF@F#Qpffb|aF$EBWsD(EMR4^0jyXCdLSO_HO_sn1hdqp{JEmjin{ zWbzzn=(ewfYqst)Gb6KSn0)tfv#KMrMYzO^{SCg=-!T;j%=vy8nW!CAfIB>Sj>Z}r z8&BZ?#+19&rzf{(L2w!7L0gqno7Qir24L>20@Q4C|4wY)ev(9q&D#fTrC}lUazO!9H2&wDtzy1+yLyLhV>7m96L!)Gi~Y%(w9OCiYp4fh6tkjgYW7MQ78 zW9ZNnU_Cb{Q>PDFbAVOMGf;%B@vu9Ky=zyQtor32+m-&>Zq_VR*}U+IJK8nG{=9o) zN4DcUOki_g;NbD1RS@8UCO+YTrUlm0@mpA&%rV9C(u>m5An}~({wkpo z`~QjELjHfTHIwB3FSe#F84Lhj#N^-q>`uSebh!Rcs|a8!#rMB$(RT}V){7qC`M+gr z{{Pqd|M)$8hX5eK!2{5T3t)w3JgxqxgNB1PJ^I~<7tOr{a13Vr*AvGIlj2G?^@DbE J6onY@e*kf-c%lFR literal 0 HcmV?d00001 From 496d6e74b0da63a9b2f246b1d806a70c3989d371 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 May 2023 15:12:18 -0400 Subject: [PATCH 20/97] Staticcheck --- server/types.go | 4 ---- user/manager.go | 1 - 2 files changed, 5 deletions(-) diff --git a/server/types.go b/server/types.go index 8fd7517..a1d1892 100644 --- a/server/types.go +++ b/server/types.go @@ -455,7 +455,3 @@ type apiStripeSubscriptionDeletedEvent struct { ID string `json:"id"` Customer string `json:"customer"` } - -type apiTwilioVerifyResponse struct { - Status string `json:"status"` -} diff --git a/user/manager.go b/user/manager.go index 8a9acf0..c57ede5 100644 --- a/user/manager.go +++ b/user/manager.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "github.com/mattn/go-sqlite3" - _ "github.com/mattn/go-sqlite3" // SQLite driver "github.com/stripe/stripe-go/v74" "golang.org/x/crypto/bcrypt" "heckel.io/ntfy/log" From 2c81773d01094be3e4fbe10add54c166469acb37 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 May 2023 22:27:48 -0400 Subject: [PATCH 21/97] Add call verification --- docs/config.md | 4 +-- docs/publish.md | 48 ++++++++++++++++---------- docs/static/img/web-phone-verify.png | Bin 0 -> 22959 bytes go.sum | 18 ---------- server/errors.go | 1 + server/server_account.go | 13 +++---- server/server_twilio.go | 13 +++---- server/types.go | 9 +++-- web/public/static/langs/en.json | 7 ++-- web/src/app/AccountApi.js | 5 +-- web/src/components/Account.js | 49 +++++++++++++++++---------- 11 files changed, 93 insertions(+), 74 deletions(-) create mode 100644 docs/static/img/web-phone-verify.png diff --git a/docs/config.md b/docs/config.md index 353a9d0..d6f6e40 100644 --- a/docs/config.md +++ b/docs/config.md @@ -868,8 +868,8 @@ are the easiest), and then configure the following options: * `twilio-from-number` is the outgoing phone number you purchased, e.g. +18775132586 * `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 -After you have configured phone calls, create a [tier](#tiers) with a call limit, and then assign it to a user. -Users may then use the `X-Call` header to receive a phone call when publishing a message. +After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`), +and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message. ## Rate limiting !!! info diff --git a/docs/publish.md b/docs/publish.md index 98f3e87..3cca6fc 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2702,16 +2702,26 @@ You can use ntfy to call a phone and **read the message out loud using text-to-s Similar to email notifications, this can be useful to blast-notify yourself on all possible channels, or to notify people that do not have the ntfy app installed on their phone. -**Phone numbers have to be previously verified** (via the web app), so this feature is **only available to authenticated users**. -To forward a message as a voice call, pass a phone number in the `X-Call` header (or its alias: `Call`), prefixed with a -plus sign and the country code, e.g. `+12223334444`. You may also simply pass `yes` as a value to pick the first of your -verified phone numbers. +**Phone numbers have to be previously verified** (via the [web app](https://ntfy.sh/account)), so this feature is +**only available to authenticated users** (no anonymous phone calls). To forward a message as a voice call, pass a phone +number in the `X-Call` header (or its alias: `Call`), prefixed with a plus sign and the country code, e.g. `+12223334444`. +You may also simply pass `yes` as a value to pick the first of your verified phone numbers. +On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans. + +

+ ![e-mail publishing](static/img/web-phone-verify.png) +
Phone number verification in the web app
+
+ +As of today, the text-to-speed voice used will only support English. If there is demand for other languages, we'll +be happy to add support for that. Please [open an issue on GitHub](https://github.com/binwiederhier/ntfy/issues). !!! info - As of today, the text-to-speed voice used will only support English. If there is demand for other languages, we'll - be happy to add support for that. Please [open an issue on GitHub](https://github.com/binwiederhier/ntfy/issues). + You are responsible for the message content, and **you must abide by the [Twilio Acceptable Use Policy](https://www.twilio.com/en-us/legal/aup)**. + This particularly means that you must not use this feature to send unsolicited messages, or messages that are illegal or + violate the rights of others. Please read the policy for details. Failure to do so may result in your account being suspended or terminated. -On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans. +Here's how you use it: === "Command line (curl)" ``` @@ -3431,17 +3441,18 @@ There are a few limitations to the API to prevent abuse and to keep the server h are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into, but just in case, let's list them all: -| Limit | Description | -|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). | -| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. | -| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. | -| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. | -| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | -| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. | -| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | -| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. | -| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | +| Limit | Description | +|----------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). | +| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 5 seconds. | +| **Daily messages** | By default, the number of messages is governed by the request limits. This can be overridden. On ntfy.sh, the daily message limit is 250. | +| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. On ntfy.sh, the daily limit is 5. | +| **Phone calls** | By default, the server does not allow any phone calls, except for users with a tier that has a call limit. | +| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | +| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. | +| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | +| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. | +| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing a higher tier. ntfy.sh offers multiple paid tiers, which allows for much hier limits than the ones listed above. @@ -3470,6 +3481,7 @@ table in their canonical form. | `X-Icon` | `Icon` | URL to use as notification [icon](#icons) | | `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client | | `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) | +| `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) | | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | | `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps | diff --git a/docs/static/img/web-phone-verify.png b/docs/static/img/web-phone-verify.png new file mode 100644 index 0000000000000000000000000000000000000000..335aeef13848541002456ebbf93ebb17669d04e9 GIT binary patch literal 22959 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 h - You have a notification from notify on topic %s. Message: + You have a message from notify on topic %s. Message: %s - End message. + End of message. - This message was sent by user %s. It will be repeated up to three times. + This message was sent by user %s. It will be repeated three times. + To unsubscribe from calls like this, remove your phone number in the notify web app. Goodbye. @@ -97,11 +98,11 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) { return string(response), nil } -func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber string) error { - ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Sending phone verification") +func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error { + ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification") data := url.Values{} data.Set("To", phoneNumber) - data.Set("Channel", "sms") + data.Set("Channel", channel) requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", s.config.TwilioVerifyBaseURL, s.config.TwilioVerifyService) req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode())) if err != nil { diff --git a/server/types.go b/server/types.go index a1d1892..3b73367 100644 --- a/server/types.go +++ b/server/types.go @@ -311,9 +311,14 @@ type apiAccountTokenResponse struct { Expires int64 `json:"expires,omitempty"` // Unix timestamp } -type apiAccountPhoneNumberRequest struct { +type apiAccountPhoneNumberVerifyRequest struct { + Number string `json:"number"` + Channel string `json:"channel"` +} + +type apiAccountPhoneNumberAddRequest struct { Number string `json:"number"` - Code string `json:"code,omitempty"` // Only supplied in "verify" call + Code string `json:"code,omitempty"` } type apiAccountTier struct { diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index f2120e5..588a1f9 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -188,17 +188,20 @@ "account_basics_password_dialog_button_submit": "Change password", "account_basics_password_dialog_current_password_incorrect": "Password incorrect", "account_basics_phone_numbers_title": "Phone numbers", - "account_basics_phone_numbers_dialog_description": "To use the call notification feature, you need to add and verify at least one phone number. Adding it will send a verification SMS to your phone.", + "account_basics_phone_numbers_dialog_description": "To use the call notification feature, you need to add and verify at least one phone number. Verification can be done via SMS or a phone call.", "account_basics_phone_numbers_description": "For phone call notifications", "account_basics_phone_numbers_no_phone_numbers_yet": "No phone numbers yet", "account_basics_phone_numbers_copied_to_clipboard": "Phone number copied to clipboard", "account_basics_phone_numbers_dialog_title": "Add phone number", "account_basics_phone_numbers_dialog_number_label": "Phone number", "account_basics_phone_numbers_dialog_number_placeholder": "e.g. +1222333444", - "account_basics_phone_numbers_dialog_send_verification_button": "Send verification", + "account_basics_phone_numbers_dialog_verify_button_sms": "Send SMS", + "account_basics_phone_numbers_dialog_verify_button_call": "Call me", "account_basics_phone_numbers_dialog_code_label": "Verification code", "account_basics_phone_numbers_dialog_code_placeholder": "e.g. 123456", "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "Call", "account_usage_title": "Usage", "account_usage_of_limit": "of {{limit}}", "account_usage_unlimited": "Unlimited", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index b5bfcd2..8908f30 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -299,14 +299,15 @@ class AccountApi { return await response.json(); // May throw SyntaxError } - async verifyPhoneNumber(phoneNumber) { + async verifyPhoneNumber(phoneNumber, channel) { const url = accountPhoneVerifyUrl(config.base_url); console.log(`[AccountApi] Sending phone verification ${url}`); await fetchOrThrow(url, { method: "PUT", headers: withBearerAuth({}, session.token()), body: JSON.stringify({ - number: phoneNumber + number: phoneNumber, + channel: channel }) }); } diff --git a/web/src/components/Account.js b/web/src/components/Account.js index b4a378e..b480ea6 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -1,13 +1,13 @@ import * as React from 'react'; import {useContext, useState} from 'react'; import { - Alert, + Alert, ButtonGroup, CardActions, CardContent, Chip, - FormControl, + FormControl, FormControlLabel, InputLabel, LinearProgress, Link, - Portal, + Portal, Radio, RadioGroup, Select, Snackbar, Stack, @@ -47,12 +47,14 @@ import {AccountContext} from "./App"; import DialogFooter from "./DialogFooter"; import {Paragraph} from "./styles"; import CloseIcon from "@mui/icons-material/Close"; -import {ContentCopy, Public} from "@mui/icons-material"; +import {Check, ContentCopy, DeleteForever, Public} from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; import DialogContentText from "@mui/material/DialogContentText"; import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; import {ProChip} from "./SubscriptionPopup"; import AddIcon from "@mui/icons-material/Add"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; const Account = () => { if (!session.exists()) { @@ -408,6 +410,7 @@ const AddPhoneNumberDialog = (props) => { const { t } = useTranslation(); const [error, setError] = useState(""); const [phoneNumber, setPhoneNumber] = useState(""); + const [channel, setChannel] = useState("sms"); const [code, setCode] = useState(""); const [sending, setSending] = useState(false); const [verificationCodeSent, setVerificationCodeSent] = useState(false); @@ -432,7 +435,7 @@ const AddPhoneNumberDialog = (props) => { const verifyPhone = async () => { try { setSending(true); - await accountApi.verifyPhoneNumber(phoneNumber); + await accountApi.verifyPhoneNumber(phoneNumber, channel); setVerificationCodeSent(true); } catch (e) { console.log(`[Account] Error sending verification`, e); @@ -471,18 +474,26 @@ const AddPhoneNumberDialog = (props) => { {t("account_basics_phone_numbers_dialog_description")} {!verificationCodeSent && - setPhoneNumber(ev.target.value)} - fullWidth - inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }} - variant="standard" - /> +
+ setPhoneNumber(ev.target.value)} + inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }} + variant="standard" + sx={{ flexGrow: 1 }} + /> + + + setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_sms")} /> + setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_call")} sx={{ marginRight: 0 }} /> + + +
} {verificationCodeSent && { From 79a3259c867d85c60dcc6fa21d77479805284f84 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 16 May 2023 22:30:38 -0400 Subject: [PATCH 22/97] Language file --- web/public/static/langs/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 588a1f9..04233b7 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -256,7 +256,7 @@ "account_upgrade_dialog_tier_features_emails_other": "{{emails}} daily emails", "account_upgrade_dialog_tier_features_calls_one": "{{calls}} daily phone calls", "account_upgrade_dialog_tier_features_calls_other": "{{calls}} daily phone calls", - "account_upgrade_dialog_tier_features_no_calls": "No daily phone calls", + "account_upgrade_dialog_tier_features_no_calls": "No phone calls", "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file", "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage", "account_upgrade_dialog_tier_price_per_month": "month", From ac029c389ed117d62c806b85c675c6dee57a0e0f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 17 May 2023 10:39:15 -0400 Subject: [PATCH 23/97] Self-review --- docs/config.md | 4 +- docs/publish.md | 2 +- go.mod | 2 +- go.sum | 2 + log/log_test.go | 24 ++++ server/errors.go | 3 +- server/server.go | 3 + server/server_account.go | 2 +- server/server_test.go | 15 ++- server/server_twilio_test.go | 8 +- server/types.go | 2 +- user/manager.go | 6 +- user/manager_test.go | 32 ++++++ web/package-lock.json | 202 ++++++++++++++++------------------ web/src/app/AccountApi.js | 10 +- web/src/components/Account.js | 17 +-- 16 files changed, 201 insertions(+), 133 deletions(-) diff --git a/docs/config.md b/docs/config.md index d6f6e40..df77e9a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -856,8 +856,8 @@ billing-contact: "phil@example.com" ``` ## Phone calls -ntfy supports phone calls via [Twilio](https://www.twilio.com/) as a phone call provider. If phone calls are enabled, -users can verify and add a phone number, and then receive phone calls when publish a message with the `X-Call` header. +ntfy supports phone calls via [Twilio](https://www.twilio.com/) as a call provider. If phone calls are enabled, +users can verify and add a phone number, and then receive phone calls when publishing a message using the `X-Call` header. See [publishing page](publish.md#phone-calls) for more details. To enable Twilio integration, sign up with [Twilio](https://www.twilio.com/), purchase a phone number (Toll free numbers diff --git a/docs/publish.md b/docs/publish.md index 3cca6fc..1b5957b 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2709,7 +2709,7 @@ You may also simply pass `yes` as a value to pick the first of your verified pho On ntfy.sh, this feature is only supported to [ntfy Pro](https://ntfy.sh/app) plans.
- ![e-mail publishing](static/img/web-phone-verify.png) + ![phone number verification](static/img/web-phone-verify.png)
Phone number verification in the web app
diff --git a/go.mod b/go.mod index 1f4c9e7..162fd94 100644 --- a/go.mod +++ b/go.mod @@ -33,7 +33,7 @@ require ( require ( cloud.google.com/go v0.110.2 // indirect - cloud.google.com/go/compute v1.19.2 // indirect + cloud.google.com/go/compute v1.19.3 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect cloud.google.com/go/iam v1.0.1 // indirect cloud.google.com/go/longrunning v0.4.2 // indirect diff --git a/go.sum b/go.sum index ff2d580..73cd9d8 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA= cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= cloud.google.com/go/compute v1.19.2 h1:GbJtPo8OKVHbVep8jvM57KidbYHxeE68LOVqouNLrDY= cloud.google.com/go/compute v1.19.2/go.mod h1:5f5a+iC1IriXYauaQ0EyQmEAEq9CGRnV5xJSQSlTV08= +cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds= +cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA= diff --git a/log/log_test.go b/log/log_test.go index ed35b49..d7ceb1c 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -198,6 +198,30 @@ func TestLog_LevelOverride_ManyOnSameField(t *testing.T) { require.Equal(t, "", File()) } +func TestLog_FieldIf(t *testing.T) { + t.Cleanup(resetState) + + var out bytes.Buffer + SetOutput(&out) + SetLevel(DebugLevel) + SetFormat(JSONFormat) + + Time(time.Unix(11, 0).UTC()). + FieldIf("trace_field", "manager", TraceLevel). // This is not logged + Field("tag", "manager"). + Debug("trace_field is not logged") + SetLevel(TraceLevel) + Time(time.Unix(12, 0).UTC()). + FieldIf("trace_field", "manager", TraceLevel). // Now it is logged + Field("tag", "manager"). + Debug("trace_field is logged") + + expected := `{"time":"1970-01-01T00:00:11Z","level":"DEBUG","message":"trace_field is not logged","tag":"manager"} +{"time":"1970-01-01T00:00:12Z","level":"DEBUG","message":"trace_field is logged","tag":"manager","trace_field":"manager"} +` + require.Equal(t, expected, out.String()) +} + func TestLog_UsingStdLogger_JSON(t *testing.T) { t.Cleanup(resetState) diff --git a/server/errors.go b/server/errors.go index b67558d..eee916b 100644 --- a/server/errors.go +++ b/server/errors.go @@ -108,11 +108,12 @@ var ( errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", "", nil} errHTTPBadRequestTierInvalid = &errHTTP{40030, http.StatusBadRequest, "invalid request: tier does not exist", "", nil} errHTTPBadRequestUserNotFound = &errHTTP{40031, http.StatusBadRequest, "invalid request: user does not exist", "", nil} - errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestPhoneCallsDisabled = &errHTTP{40032, http.StatusBadRequest, "invalid request: calling is disabled", "https://ntfy.sh/docs/config/#phone-calls", nil} errHTTPBadRequestPhoneNumberInvalid = &errHTTP{40033, http.StatusBadRequest, "invalid request: phone number invalid", "https://ntfy.sh/docs/publish/#phone-calls", nil} errHTTPBadRequestPhoneNumberNotVerified = &errHTTP{40034, http.StatusBadRequest, "invalid request: phone number not verified, or no matching verified numbers found", "https://ntfy.sh/docs/publish/#phone-calls", nil} errHTTPBadRequestAnonymousCallsNotAllowed = &errHTTP{40035, http.StatusBadRequest, "invalid request: anonymous phone calls are not allowed", "https://ntfy.sh/docs/publish/#phone-calls", nil} errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil} + errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/server.go b/server/server.go index fb44801..20b8ce0 100644 --- a/server/server.go +++ b/server/server.go @@ -937,6 +937,9 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi if email != "" { return false, false, "", "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet) } + if call != "" { + return false, false, "", "", false, errHTTPBadRequestDelayNoCall // we cannot store the phone number (yet) + } delay, err := util.ParseFutureTime(delayStr, time.Now()) if err != nil { return false, false, "", "", false, errHTTPBadRequestDelayCannotParse diff --git a/server/server_account.go b/server/server_account.go index b9997ef..a0bbaea 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -581,7 +581,7 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R return errHTTPBadRequestPhoneNumberInvalid } logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Deleting phone number") - if err := s.userManager.DeletePhoneNumber(u.ID, req.Number); err != nil { + if err := s.userManager.RemovePhoneNumber(u.ID, req.Number); err != nil { return err } return s.writeJSON(w, newSuccessResponse()) diff --git a/server/server_test.go b/server/server_test.go index adf77a7..e231ab7 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1190,7 +1190,20 @@ func TestServer_PublishDelayedEmail_Fail(t *testing.T) { "E-Mail": "test@example.com", "Delay": "20 min", }) - require.Equal(t, 400, response.Code) + require.Equal(t, 40003, toHTTPError(t, response.Body.String()).Code) +} + +func TestServer_PublishDelayedCall_Fail(t *testing.T) { + c := newTestConfig(t) + c.TwilioAccount = "AC1234567890" + c.TwilioAuthToken = "AAEAA1234567890" + c.TwilioFromNumber = "+1234567890" + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ + "Call": "yes", + "Delay": "20 min", + }) + require.Equal(t, 40037, toHTTPError(t, response.Body.String()).Code) } func TestServer_PublishEmailNoMailer_Fail(t *testing.T) { diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 5b32095..642ad75 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -43,7 +43,7 @@ func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { require.Nil(t, err) require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+notification+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+up+to+three+times.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) + require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) called.Store(true) })) defer twilioCallsServer.Close() @@ -69,7 +69,7 @@ func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { require.Nil(t, err) // Send verification code for phone number - response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444"}`, map[string]string{ + response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{ "authorization": util.BasicAuth("phil", "phil"), }) require.Equal(t, 200, response.Code) @@ -122,7 +122,7 @@ func TestServer_Twilio_Call_Success(t *testing.T) { require.Nil(t, err) require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+notification+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+up+to+three+times.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) called.Store(true) })) defer twilioServer.Close() @@ -167,7 +167,7 @@ func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) { require.Nil(t, err) require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path) require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization")) - require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+notification+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+up+to+three+times.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) + require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body)) called.Store(true) })) defer twilioServer.Close() diff --git a/server/types.go b/server/types.go index 3b73367..1e9457b 100644 --- a/server/types.go +++ b/server/types.go @@ -318,7 +318,7 @@ type apiAccountPhoneNumberVerifyRequest struct { type apiAccountPhoneNumberAddRequest struct { Number string `json:"number"` - Code string `json:"code,omitempty"` + Code string `json:"code"` // Only set when adding a phone number } type apiAccountTier struct { diff --git a/user/manager.go b/user/manager.go index c57ede5..00407ab 100644 --- a/user/manager.go +++ b/user/manager.go @@ -117,7 +117,6 @@ const ( PRIMARY KEY (user_id, phone_number), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); - CREATE UNIQUE INDEX idx_user_phone_number ON user_phone (phone_number); CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, version INT NOT NULL @@ -420,7 +419,6 @@ const ( PRIMARY KEY (user_id, phone_number), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); - CREATE UNIQUE INDEX idx_user_phone_number ON user_phone (phone_number); ` ) @@ -694,8 +692,8 @@ func (a *Manager) AddPhoneNumber(userID string, phoneNumber string) error { return nil } -// DeletePhoneNumber deletes a phone number from the user with the given user ID -func (a *Manager) DeletePhoneNumber(userID string, phoneNumber string) error { +// RemovePhoneNumber deletes a phone number from the user with the given user ID +func (a *Manager) RemovePhoneNumber(userID string, phoneNumber string) error { _, err := a.db.Exec(deletePhoneNumberQuery, userID, phoneNumber) return err } diff --git a/user/manager_test.go b/user/manager_test.go index cd2e103..de1ad6f 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -893,6 +893,38 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) { require.Nil(t, a.ResetTier("phil")) } +func TestUser_PhoneNumberAddListRemove(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + + require.Nil(t, a.AddUser("phil", "phil", RoleUser)) + phil, err := a.User("phil") + require.Nil(t, err) + require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890")) + + phoneNumbers, err := a.PhoneNumbers(phil.ID) + require.Nil(t, err) + require.Equal(t, 1, len(phoneNumbers)) + require.Equal(t, "+1234567890", phoneNumbers[0]) + + require.Nil(t, a.RemovePhoneNumber(phil.ID, "+1234567890")) + phoneNumbers, err = a.PhoneNumbers(phil.ID) + require.Nil(t, err) + require.Equal(t, 0, len(phoneNumbers)) +} + +func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) { + a := newTestManager(t, PermissionDenyAll) + + require.Nil(t, a.AddUser("phil", "phil", RoleUser)) + require.Nil(t, a.AddUser("ben", "ben", RoleUser)) + phil, err := a.User("phil") + require.Nil(t, err) + ben, err := a.User("ben") + require.Nil(t, err) + require.Nil(t, a.AddPhoneNumber(phil.ID, "+1234567890")) + require.Nil(t, a.AddPhoneNumber(ben.ID, "+1234567890")) +} + func TestSqliteCache_Migration_From1(t *testing.T) { filename := filepath.Join(t.TempDir(), "user.db") db, err := sql.Open("sqlite3", filename) diff --git a/web/package-lock.json b/web/package-lock.json index f1b4785..5457f17 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3134,14 +3134,14 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" }, "node_modules/@mui/base": { - "version": "5.0.0-beta.0", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.0.tgz", - "integrity": "sha512-ap+juKvt8R8n3cBqd/pGtZydQ4v2I/hgJKnvJRGjpSh3RvsvnDHO4rXov8MHQlH6VqpOekwgilFLGxMZjNTucA==", + "version": "5.0.0-beta.1", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.1.tgz", + "integrity": "sha512-xrkDCeu3JQE+JjJUnJnOrdQJMXwKhbV4AW+FRjMIj5i9cHK3BAuatG/iqbf1M+jklVWLk0KdbgioKwK+03aYbA==", "dependencies": { "@babel/runtime": "^7.21.0", "@emotion/is-prop-valid": "^1.2.0", "@mui/types": "^7.2.4", - "@mui/utils": "^5.12.3", + "@mui/utils": "^5.13.1", "@popperjs/core": "^2.11.7", "clsx": "^1.2.1", "prop-types": "^15.8.1", @@ -3166,9 +3166,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.0.tgz", - "integrity": "sha512-5nXz2k8Rv2ZjtQY6kXirJVyn2+ODaQuAJmXSJtLDUQDKWp3PFUj6j3bILqR0JGOs9R5ejgwz3crLKsl6GwjwkQ==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.13.1.tgz", + "integrity": "sha512-qDHtNDO72NcBQMhaWBt9EZMvNiO+OXjPg5Sdk/6LgRDw6Zr3HdEZ5n2FJ/qtYsaT/okGyCuQavQkcZCOCEVf/g==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui" @@ -3200,16 +3200,16 @@ } }, "node_modules/@mui/material": { - "version": "5.13.0", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.0.tgz", - "integrity": "sha512-ckS+9tCpAzpdJdaTF+btF0b6mF9wbXg/EVKtnoAWYi0UKXoXBAVvEUMNpLGA5xdpCdf+A6fPbVUEHs9TsfU+Yw==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.13.1.tgz", + "integrity": "sha512-qSnbJZer8lIuDYFDv19/t3s0AXYY9SxcOdhCnGvetRSfOG4gy3TkiFXNCdW5OLNveTieiMpOuv46eXUmE3ZA6A==", "dependencies": { "@babel/runtime": "^7.21.0", - "@mui/base": "5.0.0-beta.0", - "@mui/core-downloads-tracker": "^5.13.0", - "@mui/system": "^5.12.3", + "@mui/base": "5.0.0-beta.1", + "@mui/core-downloads-tracker": "^5.13.1", + "@mui/system": "^5.13.1", "@mui/types": "^7.2.4", - "@mui/utils": "^5.12.3", + "@mui/utils": "^5.13.1", "@types/react-transition-group": "^4.4.6", "clsx": "^1.2.1", "csstype": "^3.1.2", @@ -3244,12 +3244,12 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.12.3.tgz", - "integrity": "sha512-o1e7Z1Bp27n4x2iUHhegV4/Jp6H3T6iBKHJdLivS5GbwsuAE/5l4SnZ+7+K+e5u9TuhwcAKZLkjvqzkDe8zqfA==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.13.1.tgz", + "integrity": "sha512-HW4npLUD9BAkVppOUZHeO1FOKUJWAwbpy0VQoGe3McUYTlck1HezGHQCfBQ5S/Nszi7EViqiimECVl9xi+/WjQ==", "dependencies": { "@babel/runtime": "^7.21.0", - "@mui/utils": "^5.12.3", + "@mui/utils": "^5.13.1", "prop-types": "^15.8.1" }, "engines": { @@ -3301,15 +3301,15 @@ } }, "node_modules/@mui/system": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.12.3.tgz", - "integrity": "sha512-JB/6sypHqeJCqwldWeQ1MKkijH829EcZAKKizxbU2MJdxGG5KSwZvTBa5D9qiJUA1hJFYYupjiuy9ZdJt6rV6w==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.13.1.tgz", + "integrity": "sha512-BsDUjhiO6ZVAvzKhnWBHLZ5AtPJcdT+62VjnRLyA4isboqDKLg4fmYIZXq51yndg/soDK9RkY5lYZwEDku13Ow==", "dependencies": { "@babel/runtime": "^7.21.0", - "@mui/private-theming": "^5.12.3", + "@mui/private-theming": "^5.13.1", "@mui/styled-engine": "^5.12.3", "@mui/types": "^7.2.4", - "@mui/utils": "^5.12.3", + "@mui/utils": "^5.13.1", "clsx": "^1.2.1", "csstype": "^3.1.2", "prop-types": "^15.8.1" @@ -3353,13 +3353,13 @@ } }, "node_modules/@mui/utils": { - "version": "5.12.3", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.12.3.tgz", - "integrity": "sha512-D/Z4Ub3MRl7HiUccid7sQYclTr24TqUAQFFlxHQF8FR177BrCTQ0JJZom7EqYjZCdXhwnSkOj2ph685MSKNtIA==", + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.13.1.tgz", + "integrity": "sha512-6lXdWwmlUbEU2jUI8blw38Kt+3ly7xkmV9ljzY4Q20WhsJMWiNry9CX8M+TaP/HbtuyR8XKsdMgQW7h7MM3n3A==", "dependencies": { "@babel/runtime": "^7.21.0", "@types/prop-types": "^15.7.5", - "@types/react-is": "^16.7.1 || ^17.0.0", + "@types/react-is": "^18.2.0", "prop-types": "^15.8.1", "react-is": "^18.2.0" }, @@ -4016,9 +4016,9 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" }, "node_modules/@types/node": { - "version": "20.1.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz", - "integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q==" + "version": "20.1.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.7.tgz", + "integrity": "sha512-WCuw/o4GSwDGMoonES8rcvwsig77dGCMbZDrZr2x4ZZiNW4P/gcoZXe/0twgtobcTkmg9TuKflxYL/DuwDyJzg==" }, "node_modules/@types/parse-json": { "version": "4.0.0", @@ -4061,21 +4061,11 @@ } }, "node_modules/@types/react-is": { - "version": "17.0.4", - "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.4.tgz", - "integrity": "sha512-FLzd0K9pnaEvKz4D1vYxK9JmgQPiGk1lu23o1kqGsLeT0iPbRSF7b76+S5T9fD8aRa0B8bY7I/3DebEj+1ysBA==", + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-1vz2yObaQkLL7YFe/pme2cpvDsCwI1WXIfL+5eLz0MI9gFG24Re16RzUsI8t9XZn9ZWvgLNDrJBmrqXJO7GNQQ==", "dependencies": { - "@types/react": "^17" - } - }, - "node_modules/@types/react-is/node_modules/@types/react": { - "version": "17.0.59", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.59.tgz", - "integrity": "sha512-gSON5zWYIGyoBcycCE75E9+r6dCC2dHdsrVkOEiIYNU5+Q28HcBAuqvDuxHcCbMfHBHdeT5Tva/AFn3rnMKE4g==", - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "*", - "csstype": "^3.0.2" + "@types/react": "*" } }, "node_modules/@types/react-transition-group": { @@ -4175,14 +4165,14 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.5.tgz", - "integrity": "sha512-feA9xbVRWJZor+AnLNAr7A8JRWeZqHUf4T9tlP+TN04b05pFVhO5eN7/O93Y/1OUlLMHKbnJisgDURs/qvtqdg==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.6.tgz", + "integrity": "sha512-sXtOgJNEuRU5RLwPUb1jxtToZbgvq3M6FPpY4QENxoOggK+UpTxUBpj6tD8+Qh2g46Pi9We87E+eHnUw8YcGsw==", "dependencies": { "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.59.5", - "@typescript-eslint/type-utils": "5.59.5", - "@typescript-eslint/utils": "5.59.5", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/type-utils": "5.59.6", + "@typescript-eslint/utils": "5.59.6", "debug": "^4.3.4", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", @@ -4208,11 +4198,11 @@ } }, "node_modules/@typescript-eslint/experimental-utils": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.5.tgz", - "integrity": "sha512-ArcSSBifznsKNA/p4h2w3Olt/T8AZf3bNglxD8OnuTsSDJbRpjPPmI8qpr6ijyvk1J/T3GMJHwRIluS/Kuz9kA==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.6.tgz", + "integrity": "sha512-UIVfEaaHggOuhgqdpFlFQ7IN9UFMCiBR/N7uPBUyUlwNdJzYfAu9m4wbOj0b59oI/HSPW1N63Q7lsvfwTQY13w==", "dependencies": { - "@typescript-eslint/utils": "5.59.5" + "@typescript-eslint/utils": "5.59.6" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -4226,13 +4216,13 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.5.tgz", - "integrity": "sha512-NJXQC4MRnF9N9yWqQE2/KLRSOLvrrlZb48NGVfBa+RuPMN6B7ZcK5jZOvhuygv4D64fRKnZI4L4p8+M+rfeQuw==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.6.tgz", + "integrity": "sha512-7pCa6al03Pv1yf/dUg/s1pXz/yGMUBAw5EeWqNTFiSueKvRNonze3hma3lhdsOrQcaOXhbk5gKu2Fludiho9VA==", "dependencies": { - "@typescript-eslint/scope-manager": "5.59.5", - "@typescript-eslint/types": "5.59.5", - "@typescript-eslint/typescript-estree": "5.59.5", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/typescript-estree": "5.59.6", "debug": "^4.3.4" }, "engines": { @@ -4252,12 +4242,12 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.5.tgz", - "integrity": "sha512-jVecWwnkX6ZgutF+DovbBJirZcAxgxC0EOHYt/niMROf8p4PwxxG32Qdhj/iIQQIuOflLjNkxoXyArkcIP7C3A==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.6.tgz", + "integrity": "sha512-gLbY3Le9Dxcb8KdpF0+SJr6EQ+hFGYFl6tVY8VxLPFDfUZC7BHFw+Vq7bM5lE9DwWPfx4vMWWTLGXgpc0mAYyQ==", "dependencies": { - "@typescript-eslint/types": "5.59.5", - "@typescript-eslint/visitor-keys": "5.59.5" + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/visitor-keys": "5.59.6" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -4268,12 +4258,12 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.5.tgz", - "integrity": "sha512-4eyhS7oGym67/pSxA2mmNq7X164oqDYNnZCUayBwJZIRVvKpBCMBzFnFxjeoDeShjtO6RQBHBuwybuX3POnDqg==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.6.tgz", + "integrity": "sha512-A4tms2Mp5yNvLDlySF+kAThV9VTBPCvGf0Rp8nl/eoDX9Okun8byTKoj3fJ52IJitjWOk0fKPNQhXEB++eNozQ==", "dependencies": { - "@typescript-eslint/typescript-estree": "5.59.5", - "@typescript-eslint/utils": "5.59.5", + "@typescript-eslint/typescript-estree": "5.59.6", + "@typescript-eslint/utils": "5.59.6", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -4294,9 +4284,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.5.tgz", - "integrity": "sha512-xkfRPHbqSH4Ggx4eHRIO/eGL8XL4Ysb4woL8c87YuAo8Md7AUjyWKa9YMwTL519SyDPrfEgKdewjkxNCVeJW7w==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.6.tgz", + "integrity": "sha512-tH5lBXZI7T2MOUgOWFdVNUILsI02shyQvfzG9EJkoONWugCG77NDDa1EeDGw7oJ5IvsTAAGVV8I3Tk2PNu9QfA==", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -4306,12 +4296,12 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.5.tgz", - "integrity": "sha512-+XXdLN2CZLZcD/mO7mQtJMvCkzRfmODbeSKuMY/yXbGkzvA9rJyDY5qDYNoiz2kP/dmyAxXquL2BvLQLJFPQIg==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz", + "integrity": "sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA==", "dependencies": { - "@typescript-eslint/types": "5.59.5", - "@typescript-eslint/visitor-keys": "5.59.5", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/visitor-keys": "5.59.6", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -4332,16 +4322,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.5.tgz", - "integrity": "sha512-sCEHOiw+RbyTii9c3/qN74hYDPNORb8yWCoPLmB7BIflhplJ65u2PBpdRla12e3SSTJ2erRkPjz7ngLHhUegxA==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.6.tgz", + "integrity": "sha512-vzaaD6EXbTS29cVH0JjXBdzMt6VBlv+hE31XktDRMX1j3462wZCJa7VzO2AxXEXcIl8GQqZPcOPuW/Z1tZVogg==", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.59.5", - "@typescript-eslint/types": "5.59.5", - "@typescript-eslint/typescript-estree": "5.59.5", + "@typescript-eslint/scope-manager": "5.59.6", + "@typescript-eslint/types": "5.59.6", + "@typescript-eslint/typescript-estree": "5.59.6", "eslint-scope": "^5.1.1", "semver": "^7.3.7" }, @@ -4377,11 +4367,11 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.59.5", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.5.tgz", - "integrity": "sha512-qL+Oz+dbeBRTeyJTIy0eniD3uvqU7x+y1QceBismZ41hd4aBSRh8UAw4pZP0+XzLuPZmx4raNMq/I+59W2lXKA==", + "version": "5.59.6", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.6.tgz", + "integrity": "sha512-zEfbFLzB9ETcEJ4HZEEsCR9HHeNku5/Qw1jSS5McYJv5BR+ftYXwFFAH5Al+xkGaZEqowMwl7uoJjQb1YSPF8Q==", "dependencies": { - "@typescript-eslint/types": "5.59.5", + "@typescript-eslint/types": "5.59.6", "eslint-visitor-keys": "^3.3.0" }, "engines": { @@ -4956,9 +4946,9 @@ } }, "node_modules/axe-core": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", - "integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==", + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.1.tgz", + "integrity": "sha512-sCXXUhA+cljomZ3ZAwb8i1p3oOlkABzPy08ZDAoGcYuvtBPlQ1Ytde129ArXyHWDhfeewq7rlx9F+cUx2SSlkg==", "engines": { "node": ">=4" } @@ -5511,9 +5501,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001487", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001487.tgz", - "integrity": "sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==", + "version": "1.0.30001488", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz", + "integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==", "funding": [ { "type": "opencollective", @@ -6749,9 +6739,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.394", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.394.tgz", - "integrity": "sha512-0IbC2cfr8w5LxTz+nmn2cJTGafsK9iauV2r5A5scfzyovqLrxuLoxOHE5OBobP3oVIggJT+0JfKnw9sm87c8Hw==" + "version": "1.4.397", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.397.tgz", + "integrity": "sha512-jwnPxhh350Q/aMatQia31KAIQdhEsYS0fFZ0BQQlN9tfvOEwShu6ZNwI4kL/xBabjcB/nTy6lSt17kNIluJZ8Q==" }, "node_modules/emittery": { "version": "0.8.1", @@ -9146,9 +9136,9 @@ } }, "node_modules/is-core-module": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.0.tgz", - "integrity": "sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz", + "integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==", "dependencies": { "has": "^1.0.3" }, @@ -13879,9 +13869,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.12.tgz", - "integrity": "sha512-NdxGCAZdRrwVI1sy59+Wzrh+pMMHxapGnpfenDVlMEXoOcvt4pGE0JLK9YY2F5dLxcFYA/YbVQKhcGU+FtSYQg==", + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -15976,9 +15966,9 @@ } }, "node_modules/terser": { - "version": "5.17.3", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz", - "integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==", + "version": "5.17.4", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.4.tgz", + "integrity": "sha512-jcEKZw6UPrgugz/0Tuk/PVyLAPfMBJf5clnGueo45wTweoV8yh7Q7PEkhkJ5uuUbC7zAxEcG3tqNr1bstkQ8nw==", "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 8908f30..915e3bb 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -1,14 +1,16 @@ import { accountBillingPortalUrl, accountBillingSubscriptionUrl, - accountPasswordUrl, accountPhoneUrl, accountPhoneVerifyUrl, + accountPasswordUrl, + accountPhoneUrl, + accountPhoneVerifyUrl, accountReservationSingleUrl, accountReservationUrl, accountSettingsUrl, - accountSubscriptionSingleUrl, accountSubscriptionUrl, accountTokenUrl, - accountUrl, maybeWithBearerAuth, + accountUrl, + maybeWithBearerAuth, tiersUrl, withBasicAuth, withBearerAuth @@ -18,7 +20,7 @@ import subscriptionManager from "./SubscriptionManager"; import i18n from "i18next"; import prefs from "./Prefs"; import routes from "../components/routes"; -import {fetchOrThrow, throwAppError, UnauthorizedError} from "./errors"; +import {fetchOrThrow, UnauthorizedError} from "./errors"; const delayMillis = 45000; // 45 seconds const intervalMillis = 900000; // 15 minutes diff --git a/web/src/components/Account.js b/web/src/components/Account.js index b480ea6..83ef0b7 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -1,13 +1,17 @@ import * as React from 'react'; import {useContext, useState} from 'react'; import { - Alert, ButtonGroup, + Alert, CardActions, - CardContent, Chip, - FormControl, FormControlLabel, InputLabel, + CardContent, + Chip, + FormControl, + FormControlLabel, LinearProgress, Link, - Portal, Radio, RadioGroup, + Portal, + Radio, + RadioGroup, Select, Snackbar, Stack, @@ -47,14 +51,12 @@ import {AccountContext} from "./App"; import DialogFooter from "./DialogFooter"; import {Paragraph} from "./styles"; import CloseIcon from "@mui/icons-material/Close"; -import {Check, ContentCopy, DeleteForever, Public} from "@mui/icons-material"; +import {ContentCopy, Public} from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; import DialogContentText from "@mui/material/DialogContentText"; import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; import {ProChip} from "./SubscriptionPopup"; import AddIcon from "@mui/icons-material/Add"; -import ListItemIcon from "@mui/material/ListItemIcon"; -import ListItemText from "@mui/material/ListItemText"; const Account = () => { if (!session.exists()) { @@ -427,6 +429,7 @@ const AddPhoneNumberDialog = (props) => { const handleCancel = () => { if (verificationCodeSent) { setVerificationCodeSent(false); + setCode(""); } else { props.onClose(); } From 92c384374a5a74a382302a6fbb3a0dc6dcee61c8 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 17 May 2023 10:58:28 -0400 Subject: [PATCH 24/97] More self-review --- go.sum | 2 -- server/server.go | 3 ++- server/server.yml | 5 +++++ server/server_account.go | 38 +++++++++++++++++++---------------- server/server_account_test.go | 6 ++++++ server/server_test.go | 2 +- server/server_twilio_test.go | 4 ++-- server/types.go | 1 + web/package-lock.json | 8 ++++---- web/public/config.js | 3 ++- web/src/components/Account.js | 34 ++++++++++++++++--------------- 11 files changed, 62 insertions(+), 44 deletions(-) diff --git a/go.sum b/go.sum index 73cd9d8..bfaf339 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.110.2 h1:sdFPBr6xG9/wkBbfhmUz/JmZC7X6LavQgcrVINrKiVA= cloud.google.com/go v0.110.2/go.mod h1:k04UEeEtb6ZBRTv3dZz4CeJC3jKGxyhl0sAiVVquxiw= -cloud.google.com/go/compute v1.19.2 h1:GbJtPo8OKVHbVep8jvM57KidbYHxeE68LOVqouNLrDY= -cloud.google.com/go/compute v1.19.2/go.mod h1:5f5a+iC1IriXYauaQ0EyQmEAEq9CGRnV5xJSQSlTV08= cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds= cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= diff --git a/server/server.go b/server/server.go index 20b8ce0..7e8ea25 100644 --- a/server/server.go +++ b/server/server.go @@ -550,6 +550,7 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi EnableSignup: s.config.EnableSignup, EnablePayments: s.config.StripeSecretKey != "", EnableCalls: s.config.TwilioAccount != "", + EnableEmails: s.config.SMTPSenderFrom != "", EnableReservations: s.config.EnableReservations, BillingContact: s.config.BillingContact, DisallowedTopics: s.config.DisallowedTopics, @@ -911,7 +912,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi return false, false, "", "", false, errHTTPBadRequestEmailDisabled } call = readParam(r, "x-call", "call") - if call != "" && s.config.TwilioAccount == "" && s.userManager == nil { + if call != "" && (s.config.TwilioAccount == "" || s.userManager == nil) { return false, false, "", "", false, errHTTPBadRequestPhoneCallsDisabled } else if call != "" && !isBoolValue(call) && !phoneNumberRegex.MatchString(call) { return false, false, "", "", false, errHTTPBadRequestPhoneNumberInvalid diff --git a/server/server.yml b/server/server.yml index 6728d6a..7484113 100644 --- a/server/server.yml +++ b/server/server.yml @@ -146,6 +146,11 @@ # If enabled, ntfy can perform voice calls via Twilio via the "X-Call" header. # +# - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 +# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586 +# - twilio-from-number is the outgoing phone number you purchased, e.g. +18775132586 +# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 +# # twilio-account: # twilio-auth-token: # twilio-from-number: diff --git a/server/server_account.go b/server/server_account.go index a0bbaea..6e6a686 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -108,17 +108,19 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(), } } - reservations, err := s.userManager.Reservations(u.Name) - if err != nil { - return err - } - if len(reservations) > 0 { - response.Reservations = make([]*apiAccountReservation, 0) - for _, r := range reservations { - response.Reservations = append(response.Reservations, &apiAccountReservation{ - Topic: r.Topic, - Everyone: r.Everyone.String(), - }) + if s.config.EnableReservations { + reservations, err := s.userManager.Reservations(u.Name) + if err != nil { + return err + } + if len(reservations) > 0 { + response.Reservations = make([]*apiAccountReservation, 0) + for _, r := range reservations { + response.Reservations = append(response.Reservations, &apiAccountReservation{ + Topic: r.Topic, + Everyone: r.Everyone.String(), + }) + } } } tokens, err := s.userManager.Tokens(u.ID) @@ -141,12 +143,14 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis }) } } - phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) - if err != nil { - return err - } - if len(phoneNumbers) > 0 { - response.PhoneNumbers = phoneNumbers + if s.config.TwilioAccount != "" { + phoneNumbers, err := s.userManager.PhoneNumbers(u.ID) + if err != nil { + return err + } + if len(phoneNumbers) > 0 { + response.PhoneNumbers = phoneNumbers + } } } else { response.Username = user.Everyone diff --git a/server/server_account_test.go b/server/server_account_test.go index 465e4be..119efb1 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -151,6 +151,8 @@ func TestAccount_Get_Anonymous(t *testing.T) { require.Equal(t, int64(1004), account.Stats.MessagesRemaining) require.Equal(t, int64(0), account.Stats.Emails) require.Equal(t, int64(24), account.Stats.EmailsRemaining) + require.Equal(t, int64(0), account.Stats.Calls) + require.Equal(t, int64(0), account.Stats.CallsRemaining) rr = request(t, s, "POST", "/mytopic", "", nil) require.Equal(t, 200, rr.Code) @@ -498,6 +500,8 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) { func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { conf := newTestConfigWithAuthFile(t) conf.EnableSignup = true + conf.EnableReservations = true + conf.TwilioAccount = "dummy" s := newTestServer(t, conf) // Create user @@ -510,6 +514,7 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { MessageLimit: 123, MessageExpiryDuration: 86400 * time.Second, EmailLimit: 32, + CallLimit: 10, ReservationLimit: 2, AttachmentFileSizeLimit: 1231231, AttachmentTotalSizeLimit: 123123, @@ -551,6 +556,7 @@ func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) { require.Equal(t, int64(123), account.Limits.Messages) require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration) require.Equal(t, int64(32), account.Limits.Emails) + require.Equal(t, int64(10), account.Limits.Calls) require.Equal(t, int64(2), account.Limits.Reservations) require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize) require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize) diff --git a/server/server_test.go b/server/server_test.go index e231ab7..5725141 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1194,7 +1194,7 @@ func TestServer_PublishDelayedEmail_Fail(t *testing.T) { } func TestServer_PublishDelayedCall_Fail(t *testing.T) { - c := newTestConfig(t) + c := newTestConfigWithAuthFile(t) c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" c.TwilioFromNumber = "+1234567890" diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 642ad75..1b71013 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -228,7 +228,7 @@ func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) { } func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { - c := newTestConfig(t) + c := newTestConfigWithAuthFile(t) c.TwilioCallsBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" @@ -242,7 +242,7 @@ func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { } func TestServer_Twilio_Call_Anonymous(t *testing.T) { - c := newTestConfig(t) + c := newTestConfigWithAuthFile(t) c.TwilioCallsBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" diff --git a/server/types.go b/server/types.go index 1e9457b..4280f6c 100644 --- a/server/types.go +++ b/server/types.go @@ -394,6 +394,7 @@ type apiConfigResponse struct { EnableSignup bool `json:"enable_signup"` EnablePayments bool `json:"enable_payments"` EnableCalls bool `json:"enable_calls"` + EnableEmails bool `json:"enable_emails"` EnableReservations bool `json:"enable_reservations"` BillingContact string `json:"billing_contact"` DisallowedTopics []string `json:"disallowed_topics"` diff --git a/web/package-lock.json b/web/package-lock.json index 5457f17..0d2670f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16262,16 +16262,16 @@ } }, "node_modules/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.20" + "node": ">=4.2.0" } }, "node_modules/unbox-primitive": { diff --git a/web/public/config.js b/web/public/config.js index b49e440..89bbed9 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -6,12 +6,13 @@ // During web development, you may change values here for rapid testing. var config = { - base_url: "http://127.0.0.1:2586",// FIXME window.location.origin, // Change to test against a different server + base_url: window.location.origin, // Change to test against a different server app_root: "/app", enable_login: true, enable_signup: true, enable_payments: true, enable_reservations: true, + enable_emails: true, enable_calls: true, billing_contact: "", disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"] diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 83ef0b7..710510d 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -571,22 +571,24 @@ const Stats = () => { value={account.role === Role.USER ? normalize(account.stats.messages, account.limits.messages) : 100} /> - - {t("account_usage_emails_title")} - - - }> -
- {account.stats.emails.toLocaleString()} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")} -
- -
- {(account.role === Role.ADMIN || account.limits.calls > 0) && + {config.enable_emails && + + {t("account_usage_emails_title")} + + + }> +
+ {account.stats.emails.toLocaleString()} + {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")} +
+ +
+ } + {config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && {t("account_usage_calls_title")} From fc1087a42b797c75835e15d6366998471d78c281 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 17 May 2023 11:19:48 -0400 Subject: [PATCH 25/97] The last one --- server/server_middleware.go | 2 +- server/server_twilio.go | 2 ++ user/manager_test.go | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/server/server_middleware.go b/server/server_middleware.go index 0e4aff7..7aea45a 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -87,7 +87,7 @@ func (s *Server) ensureAdmin(next handleFunc) handleFunc { func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if s.config.TwilioAccount == "" { + if s.config.TwilioAccount == "" || s.userManager == nil { return errHTTPNotFound } return next(w, r, v) diff --git a/server/server_twilio.go b/server/server_twilio.go index 11f58aa..0672357 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -58,6 +58,8 @@ func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, * return "", errHTTPBadRequestPhoneNumberNotVerified } +// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message. +// Failures will be logged, but not returned to the caller. func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { u, sender := v.User(), m.Sender.String() if u != nil { diff --git a/user/manager_test.go b/user/manager_test.go index de1ad6f..5e01f49 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -910,6 +910,12 @@ func TestUser_PhoneNumberAddListRemove(t *testing.T) { phoneNumbers, err = a.PhoneNumbers(phil.ID) require.Nil(t, err) require.Equal(t, 0, len(phoneNumbers)) + + // Paranoia check: We do NOT want to keep phone numbers in there + rows, err := a.db.Query(`SELECT * FROM user_phone`) + require.Nil(t, err) + require.False(t, rows.Next()) + require.Nil(t, rows.Close()) } func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) { From 0c1cec2ae6482655d6daafe536fc9992e39d2b1f Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Thu, 18 May 2023 12:04:13 +0200 Subject: [PATCH 26/97] Add note about arrsuite and ntfy Radarr, Sonarr v4, and Prowlarr no longer _require_ the use of custom shell scripts as they have native support. --- docs/examples.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 5979637..b98182a 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -16,7 +16,7 @@ I started adding notifications pretty much all of my scripts. Typically, I just directly to the command I'm running. The following example will either send Laptop backup succeeded or ⚠️ Laptop backup failed directly to my phone: -``` +``` bash rsync -a root@laptop /backups/laptop \ && zfs snapshot ... \ && curl -H prio:low -d "Laptop backup succeeded" ntfy.sh/backups \ @@ -26,7 +26,7 @@ rsync -a root@laptop /backups/laptop \ Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with GitHub have been hopeless. In case it ever becomes available, I want to know immediately. -``` cron +``` # Check github/ntfy user */6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi ``` @@ -155,8 +155,13 @@ shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUp ``` ## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd -It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc. -Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts). + + + +Radarr, Prowlarr, and Sonarr v4 support ntfy natively under Settings > Connect. + +Sonarr v3, Readarr, and SABnzbd support custom scripts for downloads, warnings, grabs, etc. +Some simple bash scripts to achieve this are kindly provided in [nickexyz's ntfy-shellscripts repository](https://github.com/nickexyz/ntfy-shellscripts). ## Node-RED You can use the HTTP request node to send messages with [Node-RED](https://nodered.org), some examples: From f23d09f83fca68ca7646f93a92e1beeadc0cc964 Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Thu, 18 May 2023 12:31:38 +0200 Subject: [PATCH 27/97] Also update shoutrrr docs --- docs/examples.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index b98182a..8164e2b 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -136,22 +136,23 @@ You can send a message during a workflow run with curl. Here is an example sendi ``` ## Watchtower (shoutrrr) -You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send +You can use [shoutrrr](https://containrrr.dev/shoutrrr/latest/services/ntfy/) to send [Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic. Example docker-compose.yml: + ``` yaml services: watchtower: image: containrrr/watchtower environment: - WATCHTOWER_NOTIFICATIONS=shoutrrr - - WATCHTOWER_NOTIFICATION_URL=generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates + - WATCHTOWER_NOTIFICATION_URL=ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates ``` Or, if you only want to send notifications using shoutrrr: ``` -shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage" +shoutrrr send -u "ntfy://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage" ``` ## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd From db9a4f8dee79f5fadc9cbb82f2c3de5cf5862d24 Mon Sep 17 00:00:00 2001 From: Jakob Malchow Date: Wed, 17 May 2023 10:09:45 +0000 Subject: [PATCH 28/97] Translated using Weblate (Italian) Currently translated at 73.1% (261 of 357 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/it/ --- web/public/static/langs/it.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/it.json b/web/public/static/langs/it.json index 87ea04a..5ee438f 100644 --- a/web/public/static/langs/it.json +++ b/web/public/static/langs/it.json @@ -256,5 +256,8 @@ "account_basics_tier_admin_suffix_no_tier": "(nessun livello)", "account_basics_tier_basic": "Base", "account_basics_tier_free": "Gratuito", - "account_usage_emails_title": "Email inviate" + "account_usage_emails_title": "Email inviate", + "account_usage_cannot_create_portal_session": "Impossibile aprire il portale di pagamento", + "account_delete_title": "Elimina account", + "account_basics_username_description": "Hey, sei tu ❤" } From af540f0cf7572e1cf7fa11685d2132bd1a57d891 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 18 May 2023 10:13:32 -0400 Subject: [PATCH 29/97] Bump deps --- web/package-lock.json | 62 +++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 0d2670f..ddb41ec 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3501,9 +3501,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.6.1.tgz", - "integrity": "sha512-YUkWj+xs0oOzBe74OgErsuR3wVn+efrFhXBWrit50kOiED+pvQe2r6MWY0iJMQU/mSVKxvNzL4ZaYvjdX+G7ZA==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.6.2.tgz", + "integrity": "sha512-LzqpSrMK/3JBAVBI9u3NWtOhWNw5AMQfrUFYB0+bDHTSw17z++WJLsPsxAuK+oSddsxk4d7F/JcdDPM1M5YAhA==", "engines": { "node": ">=14" } @@ -4016,9 +4016,9 @@ "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" }, "node_modules/@types/node": { - "version": "20.1.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.7.tgz", - "integrity": "sha512-WCuw/o4GSwDGMoonES8rcvwsig77dGCMbZDrZr2x4ZZiNW4P/gcoZXe/0twgtobcTkmg9TuKflxYL/DuwDyJzg==" + "version": "20.2.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.2.1.tgz", + "integrity": "sha512-DqJociPbZP1lbZ5SQPk4oag6W7AyaGMO6gSfRwq3PWl4PXTwJpRQJhDq4W0kzrg3w6tJ1SwlvGZ5uKFHY13LIg==" }, "node_modules/@types/parse-json": { "version": "4.0.0", @@ -6739,9 +6739,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.397", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.397.tgz", - "integrity": "sha512-jwnPxhh350Q/aMatQia31KAIQdhEsYS0fFZ0BQQlN9tfvOEwShu6ZNwI4kL/xBabjcB/nTy6lSt17kNIluJZ8Q==" + "version": "1.4.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.399.tgz", + "integrity": "sha512-+V1aNvVgoWNWYIbMOiQ1n5fRIaY4SlQ/uRlrsCjLrUwr/3OvQgiX2f5vdav4oArVT9TnttJKcPCqjwPNyZqw/A==" }, "node_modules/emittery": { "version": "0.8.1", @@ -14397,11 +14397,11 @@ } }, "node_modules/react-router": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.11.1.tgz", - "integrity": "sha512-OZINSdjJ2WgvAi7hgNLazrEV8SGn6xrKA+MkJe9wVDMZ3zQ6fdJocUjpCUCI0cNrelWjcvon0S/QK/j0NzL3KA==", + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.11.2.tgz", + "integrity": "sha512-74z9xUSaSX07t3LM+pS6Un0T55ibUE/79CzfZpy5wsPDZaea1F8QkrsiyRnA2YQ7LwE/umaydzXZV80iDCPkMg==", "dependencies": { - "@remix-run/router": "1.6.1" + "@remix-run/router": "1.6.2" }, "engines": { "node": ">=14" @@ -14411,12 +14411,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.11.1.tgz", - "integrity": "sha512-dPC2MhoPeTQ1YUOt5uIK376SMNWbwUxYRWk2ZmTT4fZfwlOvabF8uduRKKJIyfkCZvMgiF0GSCQckmkGGijIrg==", + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.11.2.tgz", + "integrity": "sha512-JNbKtAeh1VSJQnH6RvBDNhxNwemRj7KxCzc5jb7zvDSKRnPWIFj9pO+eXqjM69gQJ0r46hSz1x4l9y0651DKWw==", "dependencies": { - "@remix-run/router": "1.6.1", - "react-router": "6.11.1" + "@remix-run/router": "1.6.2", + "react-router": "6.11.2" }, "engines": { "node": ">=14" @@ -15983,9 +15983,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.8.tgz", - "integrity": "sha512-WiHL3ElchZMsK27P8uIUh4604IgJyAW47LVXGbEoB21DbQcZ+OuMpGjVYnEUaqcWM6dO8uS2qUbA7LSCWqvsbg==", + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", + "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", "dependencies": { "@jridgewell/trace-mapping": "^0.3.17", "jest-worker": "^27.4.5", @@ -16175,9 +16175,9 @@ } }, "node_modules/tslib": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", - "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.1.tgz", + "integrity": "sha512-KaI6gPil5m9vF7DKaoXxx1ia9fxS4qG5YveErRRVknPDXXriu5M8h48YRjB6h5ZUOKuAKlSJYb0GaDe8I39fRw==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -16262,16 +16262,16 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=12.20" } }, "node_modules/unbox-primitive": { @@ -16545,9 +16545,9 @@ } }, "node_modules/webpack": { - "version": "5.82.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.82.1.tgz", - "integrity": "sha512-C6uiGQJ+Gt4RyHXXYt+v9f+SN1v83x68URwgxNQ98cvH8kxiuywWGP4XeNZ1paOzZ63aY3cTciCEQJNFUljlLw==", + "version": "5.83.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.83.1.tgz", + "integrity": "sha512-TNsG9jDScbNuB+Lb/3+vYolPplCS3bbEaJf+Bj0Gw4DhP3ioAflBb1flcRt9zsWITyvOhM96wMQNRWlSX52DgA==", "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", From 3cdd300f1cfe250a1fbbc27c96cf240eea3b9392 Mon Sep 17 00:00:00 2001 From: Christian Meis Date: Thu, 18 May 2023 15:06:15 +0000 Subject: [PATCH 30/97] Translated using Weblate (German) Currently translated at 100.0% (381 of 381 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/ --- web/public/static/langs/de.json | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index e3f5592..710265c 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -355,5 +355,29 @@ "account_upgrade_dialog_interval_yearly": "Jährlich", "account_upgrade_dialog_tier_features_messages_one": "{{messages}} tägliche Nachricht", "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserviertes Thema", - "account_upgrade_dialog_tier_features_emails_one": "{{emails}} tägliche E-Mail" + "account_upgrade_dialog_tier_features_emails_one": "{{emails}} tägliche E-Mail", + "publish_dialog_call_label": "Telefonanruf", + "publish_dialog_call_placeholder": "Telefonnummer, die angerufen werden soll, z.B. +49123456789, oder 'yes'", + "publish_dialog_chip_call_label": "Telefonanruf", + "account_basics_phone_numbers_title": "Telefonnummern", + "account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer wurde in die Zwischenablage kopiert", + "account_basics_phone_numbers_dialog_title": "Telefonnummer hinzufügen", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} Telefonanrufe pro Tag", + "account_upgrade_dialog_tier_features_no_calls": "Keine Telefonanrufe", + "publish_dialog_call_reset": "Telefonanruf entfernen", + "account_basics_phone_numbers_dialog_description": "Um die Benachrichtigung per Telefonanruf zu nutzen musst Du mindestens eine Telefonnummer hinzufügen und verifizieren. Die Verifizierung kann per SMS oder über einen Anruf erfolgen.", + "account_basics_phone_numbers_description": "Für Telefon-Benachrichtigungen", + "account_basics_phone_numbers_no_phone_numbers_yet": "Noch keine Telefonnummern", + "account_basics_phone_numbers_dialog_number_label": "Telefonnummer", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "Anruf", + "account_basics_phone_numbers_dialog_number_placeholder": "z.B. +49123456789", + "account_basics_phone_numbers_dialog_verify_button_call": "Ruf mich an", + "account_basics_phone_numbers_dialog_verify_button_sms": "SMS senden", + "account_basics_phone_numbers_dialog_code_label": "Verifizierungs-Code", + "account_basics_phone_numbers_dialog_code_placeholder": "z.B. 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Code bestätigen", + "account_usage_calls_title": "Getätigte Anrufe", + "account_usage_calls_none": "Noch keine Anrufe mit diesem Account getätigt", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} Telefonanrufe pro Tag" } From f13a654fe81450d0e2635db542108591d040719f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 18 May 2023 12:04:21 -0400 Subject: [PATCH 31/97] Phone number dropdown --- web/package-lock.json | 8 ++--- web/public/config.js | 4 +-- web/public/static/langs/de.json | 3 +- web/public/static/langs/en.json | 3 +- web/src/components/PublishDialog.js | 54 ++++++++++++++++++++--------- 5 files changed, 48 insertions(+), 24 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index ddb41ec..b5007cc 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16262,16 +16262,16 @@ } }, "node_modules/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.20" + "node": ">=4.2.0" } }, "node_modules/unbox-primitive": { diff --git a/web/public/config.js b/web/public/config.js index 89bbed9..1955b70 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -6,11 +6,11 @@ // During web development, you may change values here for rapid testing. var config = { - base_url: window.location.origin, // Change to test against a different server + base_url: "http://127.0.0.1:2586", //window.location.origin, // Change to test against a different server app_root: "/app", enable_login: true, enable_signup: true, - enable_payments: true, + enable_payments: false, enable_reservations: true, enable_emails: true, enable_calls: true, diff --git a/web/public/static/langs/de.json b/web/public/static/langs/de.json index 710265c..6343dee 100644 --- a/web/public/static/langs/de.json +++ b/web/public/static/langs/de.json @@ -357,8 +357,9 @@ "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} reserviertes Thema", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} tägliche E-Mail", "publish_dialog_call_label": "Telefonanruf", - "publish_dialog_call_placeholder": "Telefonnummer, die angerufen werden soll, z.B. +49123456789, oder 'yes'", + "publish_dialog_call_item": "Telefonnummer {{number}} anrufen", "publish_dialog_chip_call_label": "Telefonanruf", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Keine verifizierten Telefonnummern", "account_basics_phone_numbers_title": "Telefonnummern", "account_basics_phone_numbers_copied_to_clipboard": "Telefonnummer wurde in die Zwischenablage kopiert", "account_basics_phone_numbers_dialog_title": "Telefonnummer hinzufügen", diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 04233b7..5d8a3a3 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -130,7 +130,7 @@ "publish_dialog_email_placeholder": "Address to forward the notification to, e.g. phil@example.com", "publish_dialog_email_reset": "Remove email forward", "publish_dialog_call_label": "Phone call", - "publish_dialog_call_placeholder": "Phone number to call with the message, e.g. +12223334444, or 'yes'", + "publish_dialog_call_item": "Call phone number {{number}}", "publish_dialog_call_reset": "Remove phone call", "publish_dialog_attach_label": "Attachment URL", "publish_dialog_attach_placeholder": "Attach file by URL, e.g. https://f-droid.org/F-Droid.apk", @@ -144,6 +144,7 @@ "publish_dialog_chip_click_label": "Click URL", "publish_dialog_chip_email_label": "Forward to email", "publish_dialog_chip_call_label": "Phone call", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "No verified phone numbers", "publish_dialog_chip_attach_url_label": "Attach file by URL", "publish_dialog_chip_attach_file_label": "Attach local file", "publish_dialog_chip_delay_label": "Delay delivery", diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index 0353abe..bfaccfc 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -1,7 +1,17 @@ import * as React from 'react'; -import {useEffect, useRef, useState} from 'react'; +import {useContext, useEffect, useRef, useState} from 'react'; import theme from "./theme"; -import {Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, useMediaQuery} from "@mui/material"; +import { + Checkbox, + Chip, + FormControl, + FormControlLabel, + InputLabel, + Link, + Select, + Tooltip, + useMediaQuery +} from "@mui/material"; import TextField from "@mui/material/TextField"; import priority1 from "../img/priority-1.svg"; import priority2 from "../img/priority-2.svg"; @@ -29,9 +39,11 @@ import session from "../app/Session"; import routes from "./routes"; import accountApi from "../app/AccountApi"; import {UnauthorizedError} from "../app/errors"; +import {AccountContext} from "./App"; const PublishDialog = (props) => { const { t } = useTranslation(); + const { account } = useContext(AccountContext); const [baseUrl, setBaseUrl] = useState(""); const [topic, setTopic] = useState(""); const [message, setMessage] = useState(""); @@ -416,20 +428,29 @@ const PublishDialog = (props) => { setCall(""); setShowCall(false); }}> - setCall(ev.target.value)} - disabled={disabled} - type="tel" - variant="standard" + + variant="standard" + margin="dense" + > + + + } {showAttachUrl && @@ -536,11 +557,12 @@ const PublishDialog = (props) => {
{!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showCall && setShowCall(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {account?.phone_numbers?.length > 0 && !showCall && { setShowCall(true); setCall(account.phone_numbers[0]); }} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} {!showDelay && setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} {!showTopicUrl && setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} + {account && !account?.phone_numbers && }
Date: Thu, 18 May 2023 13:08:10 -0400 Subject: [PATCH 32/97] Upstream access token --- cmd/serve.go | 3 ++ docs/config.md | 6 ++++ docs/install.md | 60 +++++++++++++++++++-------------------- docs/releases.md | 42 ++++++++++++++++----------- server/config.go | 2 ++ server/server.go | 4 +++ server/server.yml | 5 ++++ server/server_test.go | 63 ++++++++++++++++++++++++++++++++++++++++- server/server_twilio.go | 9 ++++-- 9 files changed, 144 insertions(+), 50 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 4e123e9..a326374 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -64,6 +64,7 @@ var flagsServe = append( altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-access-token", Aliases: []string{"upstream_access_token"}, EnvVars: []string{"NTFY_UPSTREAM_ACCESS_TOKEN"}, Value: "", Usage: "access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), @@ -148,6 +149,7 @@ func execServe(c *cli.Context) error { enableLogin := c.Bool("enable-login") enableReservations := c.Bool("enable-reservations") upstreamBaseURL := c.String("upstream-base-url") + upstreamAccessToken := c.String("upstream-access-token") smtpSenderAddr := c.String("smtp-sender-addr") smtpSenderUser := c.String("smtp-sender-user") smtpSenderPass := c.String("smtp-sender-pass") @@ -311,6 +313,7 @@ func execServe(c *cli.Context) error { conf.DisallowedTopics = disallowedTopics conf.WebRoot = webRoot conf.UpstreamBaseURL = upstreamBaseURL + conf.UpstreamAccessToken = upstreamAccessToken conf.SMTPSenderAddr = smtpSenderAddr conf.SMTPSenderUser = smtpSenderUser conf.SMTPSenderPass = smtpSenderPass diff --git a/docs/config.md b/docs/config.md index df77e9a..66301ee 100644 --- a/docs/config.md +++ b/docs/config.md @@ -759,6 +759,7 @@ To configure it, simply set `upstream-base-url` like so: ``` yaml upstream-base-url: "https://ntfy.sh" +upstream-access-token: "..." # optional, only if rate limits exceeded, or upstream server protected ``` If set, all incoming messages will publish a poll request to the configured upstream server, containing @@ -1258,10 +1259,15 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` | | `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` | | `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | +| `twilio-account` | `NTFY_TWILIO_ACCOUNT` | *string* | - | Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 | +| `twilio-auth-token` | `NTFY_TWILIO_AUTH_TOKEN` | *string* | - | Twilio auth token, e.g. affebeef258625862586258625862586 | +| `twilio-from-number` | `NTFY_TWILIO_FROM_NUMBER` | *string* | - | Twilio outgoing phone number, e.g. +18775132586 | +| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 | | `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. | | `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. | | `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. | | `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers | +| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth | | `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. | | `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. | | `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor | diff --git a/docs/install.md b/docs/install.md index 19522bb..1d28495 100644 --- a/docs/install.md +++ b/docs/install.md @@ -29,37 +29,37 @@ deb/rpm packages. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_x86_64.tar.gz - tar zxvf ntfy_2.4.0_linux_x86_64.tar.gz - sudo cp -a ntfy_2.4.0_linux_x86_64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.4.0_linux_x86_64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_x86_64.tar.gz + tar zxvf ntfy_2.5.0_linux_x86_64.tar.gz + sudo cp -a ntfy_2.5.0_linux_x86_64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_x86_64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_armv6.tar.gz - tar zxvf ntfy_2.4.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.4.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.4.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.tar.gz + tar zxvf ntfy_2.5.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.5.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_armv7.tar.gz - tar zxvf ntfy_2.4.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.4.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.4.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.tar.gz + tar zxvf ntfy_2.5.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.5.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_arm64.tar.gz - tar zxvf ntfy_2.4.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.4.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.4.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.tar.gz + tar zxvf ntfy_2.5.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.5.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.5.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -109,7 +109,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -117,7 +117,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -125,7 +125,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -133,7 +133,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -143,28 +143,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv6" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv6.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -192,18 +192,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos. ## macOS The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. -To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_macOS_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_macOS_all.tar.gz), extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). ```bash -curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_macOS_all.tar.gz > ntfy_2.4.0_macOS_all.tar.gz -tar zxvf ntfy_2.4.0_macOS_all.tar.gz -sudo cp -a ntfy_2.4.0_macOS_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_macOS_all.tar.gz > ntfy_2.5.0_macOS_all.tar.gz +tar zxvf ntfy_2.5.0_macOS_all.tar.gz +sudo cp -a ntfy_2.5.0_macOS_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.4.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.5.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -221,7 +221,7 @@ brew install ntfy ## Windows The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well. -To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.4.0/ntfy_2.4.0_windows_x86_64.zip), +To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.5.0/ntfy_2.5.0_windows_x86_64.zip), extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file). diff --git a/docs/releases.md b/docs/releases.md index 17debf0..c645139 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,6 +2,32 @@ Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases) and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases). +## ntfy server v2.5.0 +Released May 18, 2023 + +This release brings a number of new features, including support for text-to-speech style [phone calls](publish.md#phone-calls), +an admin API to manage users and ACL (currently in beta, and hence undocumented), and support for authorized access to +upstream servers via the `upstream-access-token` config option. + +❤️ If you like ntfy, **please consider sponsoring me** via [GitHub Sponsors](https://github.com/sponsors/binwiederhier) +and [Liberapay](https://en.liberapay.com/ntfy/), or by buying a [paid plan via the web app](https://ntfy.sh/app) (20% off +if you use promo code `MYTOPIC`). ntfy will always remain open source. + +**Features:** + +* Support for text-to-speech style [phone calls](publish.md#phone-calls) using the `X-Call` header (no ticket) +* Admin API to manage users and ACL, `v1/users` + `v1/users/access` (intentionally undocumented as of now, [#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket) +* Added `upstream-access-token` config option to allow authorized access to upstream servers (no ticket) + +**Bug fixes + maintenance:** + +* Removed old ntfy website from ntfy entirely (no ticket) +* Make emoji lookup for emails more efficient ([#725](https://github.com/binwiederhier/ntfy/pull/725), thanks to [@adamantike](https://github.com/adamantike)) +* Fix potential subscriber ID clash ([#712](https://github.com/binwiederhier/ntfy/issues/712), thanks to [@peterbourgon](https://github.com/peterbourgon) for reporting, and [@dropdevrahul](https://github.com/dropdevrahul) for fixing) +* Support for `quoted-printable` in incoming emails ([#719](https://github.com/binwiederhier/ntfy/pull/719), thanks to [@Aerion](https://github.com/Aerion)) +* Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [@un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing) +* Fix web app i18n issue in account preferences ([#730](https://github.com/binwiederhier/ntfy/issues/730), thanks to [@codebude](https://github.com/codebude) for reporting) + ### ntfy server v2.4.0 Released Apr 26, 2023 @@ -1178,22 +1204,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy server v2.5.0 (UNRELEASED) - -**Features:** - -* Support for text-to-speech style [phone calls](publish.md#phone-calls) using the `X-Call` header (no ticket) -* Admin API to manage users and ACL, `v1/users` + `v1/users/access` ([#722](https://github.com/binwiederhier/ntfy/issues/722), thanks to [@CreativeWarlock](https://github.com/CreativeWarlock) for sponsoring this ticket) - -**Bug fixes + maintenance:** - -* Removed old ntfy website from ntfy entirely (no ticket) -* Make emoji lookup for emails more efficient ([#725](https://github.com/binwiederhier/ntfy/pull/725), thanks to [@adamantike](https://github.com/adamantike)) -* Fix potential subscriber ID clash ([#712](https://github.com/binwiederhier/ntfy/issues/712), thanks to [@peterbourgon](https://github.com/peterbourgon) for reporting, and [@dropdevrahul](https://github.com/dropdevrahul) for fixing) -* Support for `quoted-printable` in incoming emails ([#719](https://github.com/binwiederhier/ntfy/pull/719), thanks to [@Aerion](https://github.com/Aerion)) -* Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [@un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing) -* Fix web app i18n issue in account preferences ([#730](https://github.com/binwiederhier/ntfy/issues/730), thanks to [@codebude](https://github.com/codebude) for reporting) - ### ntfy Android app v1.16.1 (UNRELEASED) **Features:** diff --git a/server/config.go b/server/config.go index 376862a..ae96271 100644 --- a/server/config.go +++ b/server/config.go @@ -98,6 +98,7 @@ type Config struct { FirebasePollInterval time.Duration FirebaseQuotaExceededPenaltyDuration time.Duration UpstreamBaseURL string + UpstreamAccessToken string SMTPSenderAddr string SMTPSenderUser string SMTPSenderPass string @@ -182,6 +183,7 @@ func NewConfig() *Config { FirebasePollInterval: DefaultFirebasePollInterval, FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration, UpstreamBaseURL: "", + UpstreamAccessToken: "", SMTPSenderAddr: "", SMTPSenderUser: "", SMTPSenderPass: "", diff --git a/server/server.go b/server/server.go index 7e8ea25..a451baa 100644 --- a/server/server.go +++ b/server/server.go @@ -855,7 +855,11 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { logvm(v, m).Err(err).Warn("Unable to publish poll request") return } + req.Header.Set("User-Agent", "ntfy/"+s.config.Version) req.Header.Set("X-Poll-ID", m.ID) + if s.config.UpstreamAccessToken != "" { + req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken)) + } var httpClient = &http.Client{ Timeout: time.Second * 10, } diff --git a/server/server.yml b/server/server.yml index 7484113..2f16fc8 100644 --- a/server/server.yml +++ b/server/server.yml @@ -208,7 +208,12 @@ # the message ID of the original message, instructing the iOS app to poll this server for the actual message contents. # This is to prevent the upstream server and Firebase/APNS from being able to read the message. # +# - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh". +# - upstream-access-token is the token used to authenticate with the upstream server. This is only required +# if you exceed the upstream rate limits, or the uptream server requires authentication. +# # upstream-base-url: +# upstream-access-token: # Rate limiting: Total number of topics before the server rejects new topics. # diff --git a/server/server_test.go b/server/server_test.go index 5725141..0cdb490 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -18,6 +18,7 @@ import ( "runtime/debug" "strings" "sync" + "sync/atomic" "testing" "time" @@ -2491,6 +2492,66 @@ func TestServer_PublishWithUTF8MimeHeader(t *testing.T) { require.Equal(t, "ntfy 很棒", m.Tags[1]) } +func TestServer_UpstreamBaseURL_Success(t *testing.T) { + var pollID atomic.Pointer[string] + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/87c9cddf7b0105f5fe849bf084c6e600be0fde99be3223335199b4965bd7b735", r.URL.Path) + require.Equal(t, "", string(body)) + require.NotEmpty(t, r.Header.Get("X-Poll-ID")) + pollID.Store(util.String(r.Header.Get("X-Poll-ID"))) + })) + defer upstreamServer.Close() + + c := newTestConfigWithAuthFile(t) + c.BaseURL = "http://myserver.internal" + c.UpstreamBaseURL = upstreamServer.URL + s := newTestServer(t, c) + + // Send message, and wait for upstream server to receive it + response := request(t, s, "PUT", "/mytopic", `hi there`, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.NotEmpty(t, m.ID) + require.Equal(t, "hi there", m.Message) + waitFor(t, func() bool { + pID := pollID.Load() + return pID != nil && *pID == m.ID + }) +} + +func TestServer_UpstreamBaseURL_With_Access_Token_Success(t *testing.T) { + var pollID atomic.Pointer[string] + upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.Nil(t, err) + require.Equal(t, "/a1c72bcb4daf5af54d13ef86aea8f76c11e8b88320d55f1811d5d7b173bcc1df", r.URL.Path) + require.Equal(t, "Bearer tk_1234567890", r.Header.Get("Authorization")) + require.Equal(t, "", string(body)) + require.NotEmpty(t, r.Header.Get("X-Poll-ID")) + pollID.Store(util.String(r.Header.Get("X-Poll-ID"))) + })) + defer upstreamServer.Close() + + c := newTestConfigWithAuthFile(t) + c.BaseURL = "http://myserver.internal" + c.UpstreamBaseURL = upstreamServer.URL + c.UpstreamAccessToken = "tk_1234567890" + s := newTestServer(t, c) + + // Send message, and wait for upstream server to receive it + response := request(t, s, "PUT", "/mytopic1", `hi there`, nil) + require.Equal(t, 200, response.Code) + m := toMessage(t, response.Body.String()) + require.NotEmpty(t, m.ID) + require.Equal(t, "hi there", m.Message) + waitFor(t, func() bool { + pID := pollID.Load() + return pID != nil && *pID == m.ID + }) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" @@ -2592,7 +2653,7 @@ func waitForWithMaxWait(t *testing.T, maxWait time.Duration, f func() bool) { if f() { return } - time.Sleep(100 * time.Millisecond) + time.Sleep(50 * time.Millisecond) } t.Fatalf("Function f did not succeed after %v: %v", maxWait, string(debug.Stack())) } diff --git a/server/server_twilio.go b/server/server_twilio.go index 0672357..b557edf 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -87,8 +87,9 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) { if err != nil { return "", err } - req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) + req.Header.Set("User-Agent", "ntfy/"+s.config.Version) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) resp, err := http.DefaultClient.Do(req) if err != nil { return "", err @@ -110,8 +111,9 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha if err != nil { return err } - req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) + req.Header.Set("User-Agent", "ntfy/"+s.config.Version) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) resp, err := http.DefaultClient.Do(req) if err != nil { return err @@ -135,8 +137,9 @@ func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber if err != nil { return err } - req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) + req.Header.Set("User-Agent", "ntfy/"+s.config.Version) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken)) resp, err := http.DefaultClient.Do(req) if err != nil { return err From 8ddfd2459d23687846e1400deca66957fffa78ac Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 18 May 2023 13:19:46 -0400 Subject: [PATCH 33/97] config.js --- web/public/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/config.js b/web/public/config.js index 1955b70..5909be4 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -6,7 +6,7 @@ // During web development, you may change values here for rapid testing. var config = { - base_url: "http://127.0.0.1:2586", //window.location.origin, // Change to test against a different server + base_url: window.location.origin, // Change to test against a different server app_root: "/app", enable_login: true, enable_signup: true, From 3b3e6ac2cd7e9e9b635158a47dcf5e39e1884d87 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 18 May 2023 13:32:27 -0400 Subject: [PATCH 34/97] Rename twilio-from-number to twilio-phone-number --- cmd/serve.go | 10 +++++----- docs/config.md | 4 ++-- server/config.go | 4 ++-- server/server.yml | 4 ++-- server/server_test.go | 2 +- server/server_twilio.go | 2 +- server/server_twilio_test.go | 12 ++++++------ 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index a326374..5d5381b 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -74,7 +74,7 @@ var flagsServe = append( altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-account", Aliases: []string{"twilio_account"}, EnvVars: []string{"NTFY_TWILIO_ACCOUNT"}, Usage: "Twilio account SID, used for phone calls, e.g. AC123..."}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-from-number", Aliases: []string{"twilio_from_number"}, EnvVars: []string{"NTFY_TWILIO_FROM_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), @@ -159,7 +159,7 @@ func execServe(c *cli.Context) error { smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") twilioAccount := c.String("twilio-account") twilioAuthToken := c.String("twilio-auth-token") - twilioFromNumber := c.String("twilio-from-number") + twilioPhoneNumber := c.String("twilio-phone-number") twilioVerifyService := c.String("twilio-verify-service") totalTopicLimit := c.Int("global-topic-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") @@ -219,8 +219,8 @@ func execServe(c *cli.Context) error { return errors.New("cannot set enable-signup without also setting enable-login") } else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") { return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set") - } else if twilioAccount != "" && (twilioAuthToken == "" || twilioFromNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") { - return errors.New("if twilio-account is set, twilio-auth-token, twilio-from-number, twilio-verify-service, base-url, and auth-file must also be set") + } else if twilioAccount != "" && (twilioAuthToken == "" || twilioPhoneNumber == "" || twilioVerifyService == "" || baseURL == "" || authFile == "") { + return errors.New("if twilio-account is set, twilio-auth-token, twilio-phone-number, twilio-verify-service, base-url, and auth-file must also be set") } // Backwards compatibility @@ -323,7 +323,7 @@ func execServe(c *cli.Context) error { conf.SMTPServerAddrPrefix = smtpServerAddrPrefix conf.TwilioAccount = twilioAccount conf.TwilioAuthToken = twilioAuthToken - conf.TwilioFromNumber = twilioFromNumber + conf.TwilioPhoneNumber = twilioPhoneNumber conf.TwilioVerifyService = twilioVerifyService conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit diff --git a/docs/config.md b/docs/config.md index 66301ee..df1f2cd 100644 --- a/docs/config.md +++ b/docs/config.md @@ -866,7 +866,7 @@ are the easiest), and then configure the following options: * `twilio-account` is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 * `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586 -* `twilio-from-number` is the outgoing phone number you purchased, e.g. +18775132586 +* `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586 * `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`), @@ -1261,7 +1261,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | | `twilio-account` | `NTFY_TWILIO_ACCOUNT` | *string* | - | Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 | | `twilio-auth-token` | `NTFY_TWILIO_AUTH_TOKEN` | *string* | - | Twilio auth token, e.g. affebeef258625862586258625862586 | -| `twilio-from-number` | `NTFY_TWILIO_FROM_NUMBER` | *string* | - | Twilio outgoing phone number, e.g. +18775132586 | +| `twilio-phone-number` | `NTFY_TWILIO_PHONE_NUMBER` | *string* | - | Twilio outgoing phone number, e.g. +18775132586 | | `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 | | `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. | | `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. | diff --git a/server/config.go b/server/config.go index ae96271..a876926 100644 --- a/server/config.go +++ b/server/config.go @@ -108,7 +108,7 @@ type Config struct { SMTPServerAddrPrefix string TwilioAccount string TwilioAuthToken string - TwilioFromNumber string + TwilioPhoneNumber string TwilioCallsBaseURL string TwilioVerifyBaseURL string TwilioVerifyService string @@ -194,7 +194,7 @@ func NewConfig() *Config { TwilioCallsBaseURL: "https://api.twilio.com", // Override for tests TwilioAccount: "", TwilioAuthToken: "", - TwilioFromNumber: "", + TwilioPhoneNumber: "", TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests TwilioVerifyService: "", MessageLimit: DefaultMessageLengthLimit, diff --git a/server/server.yml b/server/server.yml index 2f16fc8..9c7972e 100644 --- a/server/server.yml +++ b/server/server.yml @@ -148,12 +148,12 @@ # # - twilio-account is the Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 # - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586 -# - twilio-from-number is the outgoing phone number you purchased, e.g. +18775132586 +# - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586 # - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 # # twilio-account: # twilio-auth-token: -# twilio-from-number: +# twilio-phone-number: # twilio-verify-service: # Interval in which keepalive messages are sent to the client. This is to prevent diff --git a/server/server_test.go b/server/server_test.go index 0cdb490..35d8970 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1198,7 +1198,7 @@ func TestServer_PublishDelayedCall_Fail(t *testing.T) { c := newTestConfigWithAuthFile(t) c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioFromNumber = "+1234567890" + c.TwilioPhoneNumber = "+1234567890" s := newTestServer(t, c) response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{ "Call": "yes", diff --git a/server/server_twilio.go b/server/server_twilio.go index b557edf..093abe6 100644 --- a/server/server_twilio.go +++ b/server/server_twilio.go @@ -67,7 +67,7 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) { } body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender)) data := url.Values{} - data.Set("From", s.config.TwilioFromNumber) + data.Set("From", s.config.TwilioPhoneNumber) data.Set("To", to) data.Set("Twiml", body) ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request") diff --git a/server/server_twilio_test.go b/server/server_twilio_test.go index 1b71013..af694a7 100644 --- a/server/server_twilio_test.go +++ b/server/server_twilio_test.go @@ -53,7 +53,7 @@ func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) { c.TwilioCallsBaseURL = twilioCallsServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioFromNumber = "+1234567890" + c.TwilioPhoneNumber = "+1234567890" c.TwilioVerifyService = "VA1234567890" s := newTestServer(t, c) @@ -131,7 +131,7 @@ func TestServer_Twilio_Call_Success(t *testing.T) { c.TwilioCallsBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioFromNumber = "+1234567890" + c.TwilioPhoneNumber = "+1234567890" s := newTestServer(t, c) // Add tier and user @@ -176,7 +176,7 @@ func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) { c.TwilioCallsBaseURL = twilioServer.URL c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioFromNumber = "+1234567890" + c.TwilioPhoneNumber = "+1234567890" s := newTestServer(t, c) // Add tier and user @@ -207,7 +207,7 @@ func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) { c.TwilioCallsBaseURL = "http://dummy.invalid" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioFromNumber = "+1234567890" + c.TwilioPhoneNumber = "+1234567890" s := newTestServer(t, c) // Add tier and user @@ -232,7 +232,7 @@ func TestServer_Twilio_Call_InvalidNumber(t *testing.T) { c.TwilioCallsBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioFromNumber = "+1234567890" + c.TwilioPhoneNumber = "+1234567890" s := newTestServer(t, c) response := request(t, s, "POST", "/mytopic", "test", map[string]string{ @@ -246,7 +246,7 @@ func TestServer_Twilio_Call_Anonymous(t *testing.T) { c.TwilioCallsBaseURL = "https://127.0.0.1" c.TwilioAccount = "AC1234567890" c.TwilioAuthToken = "AAEAA1234567890" - c.TwilioFromNumber = "+1234567890" + c.TwilioPhoneNumber = "+1234567890" s := newTestServer(t, c) response := request(t, s, "POST", "/mytopic", "test", map[string]string{ From df8b18bbb18f6140414351d7237f5caafde8290b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 18 May 2023 13:51:58 -0400 Subject: [PATCH 35/97] Logo in rpm file --- .goreleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 9ba8bb4..131a302 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -97,7 +97,7 @@ nfpms: - dst: /var/lib/ntfy type: dir - dst: /usr/share/ntfy/logo.png - src: web/public/static/img/ntfy.png + src: web/public/static/images/ntfy.png scripts: preinstall: "scripts/preinst.sh" postinstall: "scripts/postinst.sh" From 57eabd3aa54944a7943eb5e3332813e65a83cf80 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 18 May 2023 15:08:40 -0400 Subject: [PATCH 36/97] Thank you @darkdragon-001 for your donation --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 77c7815..f2284b6 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ account costs. Even small donations are very much appreciated. A big fat **Thank + I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free, and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project: From 4ad0fb1f571c94ada28729df23cf4618ae36f92a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 19 May 2023 09:25:25 -0400 Subject: [PATCH 37/97] Fix docs ToC parsing issue --- docs/publish.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/publish.md b/docs/publish.md index 1b5957b..80d05d1 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -2903,6 +2903,7 @@ Here's an example with a user `testuser` and password `fakepassword`: ``` === "PowerShell 5 and earlier" + ``` powershell # With PowerShell 5 or earlier, we need to create the base64 username:password string ourselves $CredentialString = "$($Credential.Username):$($Credential.GetNetworkCredential().Password)" $EncodedCredential = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CredentialString)) From 5345b9063c1ab75e2deddacb84ef355db36a2261 Mon Sep 17 00:00:00 2001 From: Shoshin Akamine Date: Fri, 19 May 2023 08:47:02 +0000 Subject: [PATCH 38/97] Translated using Weblate (Japanese) Currently translated at 93.9% (358 of 381 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ja/ --- web/public/static/langs/ja.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/ja.json b/web/public/static/langs/ja.json index 7eb1c7d..9c68679 100644 --- a/web/public/static/langs/ja.json +++ b/web/public/static/langs/ja.json @@ -355,5 +355,6 @@ "account_upgrade_dialog_billing_contact_website": "支払いに関する質問は、ウェブサイトを参照して下さい。", "account_upgrade_dialog_tier_features_messages_one": "毎日 {{messages}} メッセージ", "account_upgrade_dialog_tier_features_reservations_one": "予約済みトピック {{reservations}} 件", - "account_upgrade_dialog_tier_features_emails_one": "毎日メール {{emails}} 件" + "account_upgrade_dialog_tier_features_emails_one": "毎日メール {{emails}} 件", + "publish_dialog_call_label": "電話" } From 7140f185745cc5a8bd508af307d407f56f18df45 Mon Sep 17 00:00:00 2001 From: Andrew Date: Fri, 19 May 2023 11:40:56 +0000 Subject: [PATCH 39/97] Translated using Weblate (Ukrainian) Currently translated at 77.9% (297 of 381 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/ --- web/public/static/langs/uk.json | 60 ++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/uk.json b/web/public/static/langs/uk.json index 8683769..111292f 100644 --- a/web/public/static/langs/uk.json +++ b/web/public/static/langs/uk.json @@ -237,5 +237,63 @@ "display_name_dialog_description": "Задайте альтернативну назву для теми, яка відображатиметься у списку підписок. Це допоможе легше ідентифікувати теми зі складними назвами.", "display_name_dialog_placeholder": "Відображуване ім'я", "account_basics_password_title": "Пароль", - "account_basics_username_admin_tooltip": "Ви адміністратор" + "account_basics_username_admin_tooltip": "Ви адміністратор", + "account_basics_tier_interval_monthly": "щомісяця", + "common_copy_to_clipboard": "Скопіювати в буфер обміну", + "account_basics_phone_numbers_title": "Номери телефонів", + "account_basics_phone_numbers_description": "Для сповіщень через телефонні дзвінки", + "account_basics_phone_numbers_no_phone_numbers_yet": "Поки що немає номерів телефонів", + "account_basics_phone_numbers_copied_to_clipboard": "Номер телефону скопійовано в буфер обміну", + "account_basics_phone_numbers_dialog_title": "Додати номер телефону", + "account_basics_phone_numbers_dialog_number_label": "Номер телефону", + "account_basics_phone_numbers_dialog_number_placeholder": "наприклад, +1222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "Надіслати SMS", + "account_basics_phone_numbers_dialog_verify_button_call": "Зателефонуйте мені", + "account_basics_phone_numbers_dialog_code_label": "Код підтвердження", + "account_basics_phone_numbers_dialog_code_placeholder": "наприклад, 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Підтвердити код", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_basics_phone_numbers_dialog_channel_call": "Дзвінок", + "account_basics_tier_interval_yearly": "щороку", + "account_usage_calls_title": "Здійснені телефонні дзвінки", + "account_usage_calls_none": "З цього облікового запису не можна здійснювати телефонні дзвінки", + "account_usage_attachment_storage_title": "Зберігання вкладень", + "account_usage_attachment_storage_description": "{{filesize}} на файл, видаляється після {{expiry}}", + "account_usage_basis_ip_description": "Статистика використання та ліміти для цього облікового запису базуються на вашій IP-адресі, тому вони можуть бути доступні іншим користувачам. Ліміти, показані вище, є приблизними і базуються на існуючих лімітах тарифів.", + "account_usage_cannot_create_portal_session": "Не вдається відкрити білінговий портал", + "account_delete_title": "Видалення облікового запису", + "account_delete_description": "Назавжди видалити свій обліковий запис", + "account_delete_dialog_label": "Пароль", + "account_delete_dialog_button_cancel": "Скасувати", + "account_delete_dialog_button_submit": "Видалити обліковий запис назавжди", + "account_delete_dialog_billing_warning": "Видалення облікового запису також негайно скасовує вашу підписку. Ви більше не матимете доступу до білінгової панелі.", + "account_upgrade_dialog_title": "Зміна рівня облікового запису", + "account_upgrade_dialog_interval_monthly": "Щомісяця", + "account_upgrade_dialog_interval_yearly": "Щорічно", + "account_upgrade_dialog_interval_yearly_discount_save": "економія {{discount}}%", + "account_upgrade_dialog_interval_yearly_discount_save_up_to": "економія до {{discount}}%", + "publish_dialog_call_label": "Телефонний дзвінок", + "publish_dialog_call_placeholder": "Номер телефону, на який потрібно зателефонувати з повідомленням, наприклад, +12223334444 або \"yes\"", + "publish_dialog_chip_call_label": "Телефонний дзвінок", + "publish_dialog_call_reset": "Видалити телефонний дзвінок", + "account_basics_phone_numbers_dialog_description": "Щоб користуватися функцією сповіщення про дзвінки, потрібно додати та верифікувати принаймні один телефонний номер. Верифікацію можна здійснити за допомогою SMS або телефонного дзвінка.", + "account_delete_dialog_description": "Це призведе до остаточного видалення вашого облікового запису, включаючи всі дані, які зберігаються на сервері. Після видалення ваше ім'я користувача буде недоступне протягом 7 днів. Якщо ви дійсно хочете продовжити, будь ласка, підтвердьте свій пароль у полі нижче.", + "account_basics_tier_upgrade_button": "Оновлення до Pro", + "account_basics_password_description": "Зміна пароля облікового запису", + "account_usage_of_limit": "з {{limit}}", + "account_usage_unlimited": "Без обмежень", + "account_basics_tier_description": "Рівень потужності вашого облікового запису", + "account_basics_tier_admin_suffix_with_tier": "(з рівнем {{tier}})", + "account_basics_tier_admin_suffix_no_tier": "(без рівня)", + "account_basics_tier_basic": "Базовий", + "account_basics_tier_free": "Безкоштовний", + "account_basics_tier_change_button": "Змінити", + "account_basics_tier_paid_until": "Підписка оплачена до {{date}} і буде автоматично поновлюватися", + "account_basics_tier_payment_overdue": "Ваш платіж прострочено. Будь ласка, оновіть спосіб оплати, інакше ваш обліковий запис буде знижено до нижчого рівня.", + "account_basics_tier_canceled_subscription": "Вашу підписку було скасовано, і з {{date}} вона буде знижена до безкоштовного акаунта.", + "account_basics_tier_manage_billing_button": "Керувати рахунками", + "account_usage_messages_title": "Опубліковані повідомлення", + "account_usage_emails_title": "Надіслані електронні листи", + "account_usage_reservations_title": "Зарезервовані теми", + "account_usage_reservations_none": "Для цього облікового запису немає зарезервованих тем" } From 7838b253b4d627b29567e3196183bfcbe817e707 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 21 May 2023 17:26:29 -0400 Subject: [PATCH 40/97] Android release notes --- docs/releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releases.md b/docs/releases.md index c645139..633fa15 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1214,6 +1214,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * UnifiedPush subscriptions now include the `Rate-Topics` header to facilitate subscriber-based billing ([#652](https://github.com/binwiederhier/ntfy/issues/652), thanks to [@wunter8](https://github.com/wunter8)) * Subscriptions without icons no longer appear to use another subscription's icon ([#634](https://github.com/binwiederhier/ntfy/issues/634), thanks to [@topcaser](https://github.com/topcaser) for reporting and to [@wunter8](https://github.com/wunter8) for fixing) +* Bumped all dependencies to the latest versions (no ticket) **Additional languages:** From b3a299ce2225fcc9079be2b875134fe7c505b4d1 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 21 May 2023 17:27:24 -0400 Subject: [PATCH 41/97] You rock Jonathan. Thank you for your sponsorship @jonathan-kosgei --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index f2284b6..cebf55b 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ account costs. Even small donations are very much appreciated. A big fat **Thank + I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free, and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project: From 89cf84b63e21d92043991dff427aa9d4135aa1f7 Mon Sep 17 00:00:00 2001 From: Linerly Date: Sat, 20 May 2023 22:33:16 +0000 Subject: [PATCH 42/97] Translated using Weblate (Indonesian) Currently translated at 100.0% (381 of 381 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/ --- web/public/static/langs/id.json | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/id.json b/web/public/static/langs/id.json index 51e6a98..76c9d1d 100644 --- a/web/public/static/langs/id.json +++ b/web/public/static/langs/id.json @@ -355,5 +355,29 @@ "account_upgrade_dialog_billing_contact_website": "Untuk pertanyaan penagihan, silakan menuju ke situs web kami.", "account_upgrade_dialog_tier_features_reservations_one": "{{reservations}} topik yang direservasi", "account_upgrade_dialog_tier_features_emails_one": "{{emails}} surel harian", - "account_upgrade_dialog_tier_features_messages_one": "{{messages}} pesan harian" + "account_upgrade_dialog_tier_features_messages_one": "{{messages}} pesan harian", + "publish_dialog_call_label": "Panggilan telepon", + "publish_dialog_call_placeholder": "Nomor telepon untuk dipanggil dengan pesan, mis. +622223334444, atau 'yes'", + "account_basics_phone_numbers_title": "Nomor telepon", + "account_basics_phone_numbers_dialog_description": "Untuk menggunakan fitur notifikasi telepon, Anda perlu menambahkan dan memverifikasi setidaknya satu nomor telepon. Verifikasi dapat dilakukan melalui SMS atau panggilan telepon.", + "account_basics_phone_numbers_no_phone_numbers_yet": "Belum ada nomor telepon", + "account_basics_phone_numbers_dialog_title": "Tambahkan nomor telepon", + "account_basics_phone_numbers_dialog_number_label": "Nomor telepon", + "account_basics_phone_numbers_dialog_number_placeholder": "mis. +62222333444", + "account_basics_phone_numbers_dialog_verify_button_sms": "Kirim SMS", + "account_basics_phone_numbers_dialog_channel_call": "Panggil", + "account_usage_calls_title": "Panggilan telepon dilakukan", + "account_usage_calls_none": "Tidak ada panggilan telepon yang dapat dilakukan dengan akun ini", + "account_upgrade_dialog_tier_features_calls_other": "{{calls}} panggilan telepon harian", + "publish_dialog_call_reset": "Hapus panggilan telepon", + "account_basics_phone_numbers_description": "Untuk notifikasi panggilan telepon", + "account_basics_phone_numbers_copied_to_clipboard": "Nomor telepon disalin ke papan klip", + "publish_dialog_chip_call_label": "Panggilan telepon", + "account_basics_phone_numbers_dialog_verify_button_call": "Panggil saya", + "account_basics_phone_numbers_dialog_code_placeholder": "mis. 123456", + "account_basics_phone_numbers_dialog_check_verification_button": "Konfirmasi kode", + "account_basics_phone_numbers_dialog_channel_sms": "SMS", + "account_upgrade_dialog_tier_features_calls_one": "{{calls}} panggilan telepon harian", + "account_upgrade_dialog_tier_features_no_calls": "Tidak ada panggilan telepon", + "account_basics_phone_numbers_dialog_code_label": "Kode verifikasi" } From 168ad8bf1b46fc1edc5fb1388e0ce86e2b73c9ef Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 21 May 2023 20:56:56 -0400 Subject: [PATCH 43/97] Support encoding any header as RFC 2047 --- docs/publish.md | 18 +++++++++++++----- docs/releases.md | 11 +++++++++-- server/server.go | 10 +++++----- server/server_test.go | 11 +++++++++-- server/types.go | 1 + server/util.go | 2 +- 6 files changed, 38 insertions(+), 15 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 80d05d1..4180124 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -393,8 +393,8 @@ you can set the `X-Title` header (or any of its aliases: `Title`, `ti`, or `t`). !!! info ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). - If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the `X-Title` or `X-Message` - header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), + If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including the title) + as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). ## Message priority @@ -619,7 +619,7 @@ them with a comma, e.g. `tag1,tag2,tag3`. !!! info ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). - If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the individual tags + If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the tags header or individual tags as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `tag1,=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), or `=?UTF-8?Q?=C3=84pfel?=,tag2` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). @@ -1004,9 +1004,11 @@ all the supported fields: | `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications | | `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) | | `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-url) | +| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | | `filename` | - | *string* | `file.jpg` | File name of the attachment | | `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | | `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | +| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | ## Action buttons _Supported on:_ :material-android: :material-apple: :material-firefox: @@ -1139,7 +1141,13 @@ As an example, here's how you can create the above notification using this forma ] ])); ``` - + +!!! info + ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). + If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header (including actions) + as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), + or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). + #### Using a JSON array Alternatively, the same actions can be defined as **JSON array**, if the notification is defined as part of the JSON body (see [publish as JSON](#publish-as-json)): @@ -3465,7 +3473,7 @@ table in their canonical form. !!! info ntfy supports UTF-8 in HTTP headers, but [not every library or programming language does](https://www.jmix.io/blog/utf-8-in-http-headers/). - If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode the `X-Title` or `X-Message` + If non-ASCII characters are causing issues for you in the title (i.e. you're seeing `?` symbols), you may also encode any header as [RFC 2047](https://datatracker.ietf.org/doc/html/rfc2047#section-2), e.g. `=?UTF-8?B?8J+HqfCfh6o=?=` ([base64](https://en.wikipedia.org/wiki/Base64)), or `=?UTF-8?Q?=C3=84pfel?=` ([quoted-printable](https://en.wikipedia.org/wiki/Quoted-printable)). diff --git a/docs/releases.md b/docs/releases.md index 633fa15..89ecc51 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -28,7 +28,7 @@ if you use promo code `MYTOPIC`). ntfy will always remain open source. * Attachments with filenames that are downloaded using a browser will now download with the proper filename ([#726](https://github.com/binwiederhier/ntfy/issues/726), thanks to [@un99known99](https://github.com/un99known99) for reporting, and [@wunter8](https://github.com/wunter8) for fixing) * Fix web app i18n issue in account preferences ([#730](https://github.com/binwiederhier/ntfy/issues/730), thanks to [@codebude](https://github.com/codebude) for reporting) -### ntfy server v2.4.0 +## ntfy server v2.4.0 Released Apr 26, 2023 This release adds a tiny `v1/stats` endpoint to expose how many messages have been published, and adds suport to encode the `X-Title`, @@ -57,7 +57,7 @@ will always remain open source. * Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/Shjosan/)) -### ntfy server v2.3.1 +## ntfy server v2.3.1 Released March 30, 2023 This release disables server-initiated polling of iOS devices entirely, thereby eliminating the thundering herd problem @@ -1219,3 +1219,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Additional languages:** * Swedish (thanks to [@hellbown](https://hosted.weblate.org/user/hellbown/)) + +### ntfy server v2.6.0 (UNRELEASED) + +**Bug fixes + maintenance:** + +* Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting) + diff --git a/server/server.go b/server/server.go index a451baa..14a8c7f 100644 --- a/server/server.go +++ b/server/server.go @@ -876,7 +876,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, unifiedpush bool, err *errHTTP) { cache = readBoolParam(r, true, "x-cache", "cache") firebase = readBoolParam(r, true, "x-firebase", "firebase") - m.Title = maybeDecodeHeader(readParam(r, "x-title", "title", "t")) + m.Title = readParam(r, "x-title", "title", "t") m.Click = readParam(r, "x-click", "click") icon := readParam(r, "x-icon", "icon") filename := readParam(r, "x-filename", "filename", "file", "f") @@ -923,7 +923,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi } messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n") if messageStr != "" { - m.Message = maybeDecodeHeader(messageStr) + m.Message = messageStr } var e error m.Priority, e = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p")) @@ -931,9 +931,6 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi return false, false, "", "", false, errHTTPBadRequestPriorityInvalid } m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") - for i, t := range m.Tags { - m.Tags[i] = maybeDecodeHeader(t) - } delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") if delayStr != "" { if !cache { @@ -1747,6 +1744,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc { if m.Delay != "" { r.Header.Set("X-Delay", m.Delay) } + if m.Call != "" { + r.Header.Set("X-Call", m.Call) + } return next(w, r, v) } } diff --git a/server/server_test.go b/server/server_test.go index 35d8970..fe84b85 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2478,18 +2478,25 @@ func TestServer_PublishWithUTF8MimeHeader(t *testing.T) { s := newTestServer(t, newTestConfig(t)) response := request(t, s, "POST", "/mytopic", "some attachment", map[string]string{ - "X-Filename": "some attachment.txt", + "X-Filename": "some =?UTF-8?q?=C3=A4?=ttachment.txt", "X-Message": "=?UTF-8?B?8J+HqfCfh6o=?=", "X-Title": "=?UTF-8?B?bnRmeSDlvojmo5I=?=, no really I mean it! =?UTF-8?Q?This is q=C3=BC=C3=B6ted-print=C3=A4ble.?=", "X-Tags": "=?UTF-8?B?8J+HqfCfh6o=?=, =?UTF-8?B?bnRmeSDlvojmo5I=?=", + "X-Click": "=?uTf-8?b?aHR0cHM6Ly/wn5KpLmxh?=", + "X-Actions": "http, \"=?utf-8?q?Mettre =C3=A0 jour?=\", \"https://my.tld/webhook/netbird-update\"; =?utf-8?b?aHR0cCwg6L+Z5piv5LiA5Liq5qCH562+LCBodHRwczovL/CfkqkubGE=?=", }) require.Equal(t, 200, response.Code) m := toMessage(t, response.Body.String()) require.Equal(t, "🇩🇪", m.Message) require.Equal(t, "ntfy 很棒, no really I mean it! This is qüöted-printäble.", m.Title) - require.Equal(t, "some attachment.txt", m.Attachment.Name) + require.Equal(t, "some ättachment.txt", m.Attachment.Name) require.Equal(t, "🇩🇪", m.Tags[0]) require.Equal(t, "ntfy 很棒", m.Tags[1]) + require.Equal(t, "https://💩.la", m.Click) + require.Equal(t, "Mettre à jour", m.Actions[0].Label) + require.Equal(t, "http", m.Actions[1].Action) + require.Equal(t, "这是一个标签", m.Actions[1].Label) + require.Equal(t, "https://💩.la", m.Actions[1].URL) } func TestServer_UpstreamBaseURL_Success(t *testing.T) { diff --git a/server/types.go b/server/types.go index 4280f6c..9e4ff55 100644 --- a/server/types.go +++ b/server/types.go @@ -101,6 +101,7 @@ type publishMessage struct { Attach string `json:"attach"` Filename string `json:"filename"` Email string `json:"email"` + Call string `json:"call"` Delay string `json:"delay"` } diff --git a/server/util.go b/server/util.go index a3a4554..03eb866 100644 --- a/server/util.go +++ b/server/util.go @@ -50,7 +50,7 @@ func readParam(r *http.Request, names ...string) string { func readHeaderParam(r *http.Request, names ...string) string { for _, name := range names { - value := r.Header.Get(name) + value := maybeDecodeHeader(r.Header.Get(name)) if value != "" { return strings.TrimSpace(value) } From 5379474c410ea87579d9ebb075a571fc090127d4 Mon Sep 17 00:00:00 2001 From: ksurl Date: Tue, 23 May 2023 01:20:56 +0000 Subject: [PATCH 44/97] add docs for generating query param for access token --- docs/publish.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/publish.md b/docs/publish.md index 4180124..11e33e6 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3238,6 +3238,12 @@ The following command will generate the appropriate value for you on *nix system echo -n "Basic `echo -n 'testuser:fakepassword' | base64`" | base64 | tr -d '=' ``` +For access tokens, you can use this instead: + +``` +echo -n "Bearer faketoken" | base64 | tr -d '=' +``` + ## Advanced features ### Message caching From da06ae448528b08215c69fa90de5162869ac6623 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 23 May 2023 13:20:43 -0400 Subject: [PATCH 45/97] Clarify error message for poll requests --- server/server.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/server.go b/server/server.go index 14a8c7f..d7630aa 100644 --- a/server/server.go +++ b/server/server.go @@ -868,7 +868,11 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { logvm(v, m).Err(err).Warn("Unable to publish poll request") return } else if response.StatusCode != http.StatusOK { - logvm(v, m).Err(err).Warn("Unable to publish poll request, unexpected HTTP status: %d", response.StatusCode) + if response.StatusCode == http.StatusTooManyRequests { + logvm(v, m).Err(err).Warn("Unable to publish poll request, the upstream server %s responded with HTTP %s; you may solve this by sending fewer daily messages, or by configuring upstream-access-token (assuming you have an account with higher rate limits) ", s.config.UpstreamBaseURL, response.Status) + } else { + logvm(v, m).Err(err).Warn("Unable to publish poll request to %s, the upstream server %s responded with HTTP %s", s.config.UpstreamBaseURL, response.Status) + } return } } From 6bd4c8fb71b6c8b702c351aab03308a272dcd71a Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Tue, 23 May 2023 20:16:38 +0200 Subject: [PATCH 46/97] [web] remove unused @emotion packages --- web/package-lock.json | 32 ++++++++++++++++++++++++++------ web/package.json | 2 -- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index b5007cc..b978749 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,8 +8,6 @@ "name": "ntfy", "version": "1.0.0", "dependencies": { - "@emotion/react": "^11.8.2", - "@emotion/styled": "^11.8.1", "@mui/icons-material": "^5.4.2", "@mui/material": "latest", "dexie": "^3.2.1", @@ -2148,6 +2146,8 @@ "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", + "optional": true, + "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", @@ -2177,7 +2177,9 @@ "node_modules/@emotion/hash": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", - "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==", + "optional": true, + "peer": true }, "node_modules/@emotion/is-prop-valid": { "version": "1.2.1", @@ -2196,6 +2198,8 @@ "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.0.tgz", "integrity": "sha512-ZSK3ZJsNkwfjT3JpDAWJZlrGD81Z3ytNDsxw1LKq1o+xkmO5pnWfr6gmCC8gHEFf3nSSX/09YrG67jybNPxSUw==", + "optional": true, + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -2219,6 +2223,8 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz", "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==", + "optional": true, + "peer": true, "dependencies": { "@emotion/hash": "^0.9.1", "@emotion/memoize": "^0.8.1", @@ -2236,6 +2242,8 @@ "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", + "optional": true, + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -2257,12 +2265,16 @@ "node_modules/@emotion/unitless": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", - "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==", + "optional": true, + "peer": true }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "optional": true, + "peer": true, "peerDependencies": { "react": ">=16.8.0" } @@ -7997,7 +8009,9 @@ "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "optional": true, + "peer": true }, "node_modules/find-up": { "version": "5.0.0", @@ -8632,6 +8646,8 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "optional": true, + "peer": true, "dependencies": { "react-is": "^16.7.0" } @@ -8639,7 +8655,9 @@ "node_modules/hoist-non-react-statics/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "optional": true, + "peer": true }, "node_modules/hoopy": { "version": "0.1.4", @@ -15309,6 +15327,8 @@ "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } diff --git a/web/package.json b/web/package.json index 9e919ef..f26eba9 100644 --- a/web/package.json +++ b/web/package.json @@ -9,8 +9,6 @@ "eject": "react-scripts eject" }, "dependencies": { - "@emotion/react": "^11.8.2", - "@emotion/styled": "^11.8.1", "@mui/icons-material": "^5.4.2", "@mui/material": "latest", "dexie": "^3.2.1", From f8a00dd41123a7dd303d360068f8629873693410 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 23 May 2023 14:24:11 -0400 Subject: [PATCH 47/97] Fix test --- server/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/server.go b/server/server.go index d7630aa..ac54aa5 100644 --- a/server/server.go +++ b/server/server.go @@ -871,7 +871,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) { if response.StatusCode == http.StatusTooManyRequests { logvm(v, m).Err(err).Warn("Unable to publish poll request, the upstream server %s responded with HTTP %s; you may solve this by sending fewer daily messages, or by configuring upstream-access-token (assuming you have an account with higher rate limits) ", s.config.UpstreamBaseURL, response.Status) } else { - logvm(v, m).Err(err).Warn("Unable to publish poll request to %s, the upstream server %s responded with HTTP %s", s.config.UpstreamBaseURL, response.Status) + logvm(v, m).Err(err).Warn("Unable to publish poll request, the upstream server %s responded with HTTP %s", s.config.UpstreamBaseURL, response.Status) } return } From ceedca4e271f974764968e17ad68dabcd3a80107 Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Tue, 23 May 2023 20:50:20 +0200 Subject: [PATCH 48/97] Update GitHub Actions - Use the newest versions to solve the deprecation warning - Remove the cache step as the newest go and node actions have built-in caching - Add the official actions@github.com email address --- .github/workflows/build.yaml | 19 +++++-------------- .github/workflows/docs.yaml | 2 +- .github/workflows/release.yaml | 19 +++++-------------- .github/workflows/test.yaml | 19 +++++-------------- 4 files changed, 16 insertions(+), 43 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 25c5047..f9fc481 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,28 +6,19 @@ jobs: steps: - name: Install Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: go-version: '1.19.x' - name: Install node - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: '18' + cache: 'npm' + cache-dependency-path: './web/package-lock.json' - name: Checkout code - uses: actions/checkout@v2 - - - name: Cache Go and npm modules - uses: actions/cache@v3 - with: - path: | - ~/go/pkg/mod - ~/go/bin - ~/.npm - web/node_modules - key: ${{ runner.os }}-ntfy-${{ hashFiles('go.sum', 'web/package.lock') }} - restore-keys: ${{ runner.os }}-ntfy- + uses: actions/checkout@v3 - name: Install dependencies run: make build-deps-ubuntu diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 2ba9b9c..6991dea 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -30,7 +30,7 @@ jobs: run: | cd build/ntfy-docs.github.io git config user.name "GitHub Actions Bot" - git config user.email "<>" + git config user.email "" git add docs/ git commit -m "Updated docs" git push origin main diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 32c1409..e6e30e0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,28 +9,19 @@ jobs: steps: - name: Install Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: go-version: '1.19.x' - name: Install node - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: '18' + cache: 'npm' + cache-dependency-path: './web/package-lock.json' - name: Checkout code - uses: actions/checkout@v2 - - - name: Cache Go and npm modules - uses: actions/cache@v3 - with: - path: | - ~/go/pkg/mod - ~/go/bin - ~/.npm - web/node_modules - key: ${{ runner.os }}-ntfy-${{ hashFiles('go.sum', 'web/package.lock') }} - restore-keys: ${{ runner.os }}-ntfy- + uses: actions/checkout@v3 - name: Docker login uses: docker/login-action@v2 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index da7bcf5..f04162c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -6,28 +6,19 @@ jobs: steps: - name: Install Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: go-version: '1.19.x' - name: Install node - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: '18' + cache: 'npm' + cache-dependency-path: './web/package-lock.json' - name: Checkout code - uses: actions/checkout@v2 - - - name: Cache Go and npm modules - uses: actions/cache@v3 - with: - path: | - ~/go/pkg/mod - ~/go/bin - ~/.npm - web/node_modules - key: ${{ runner.os }}-ntfy-${{ hashFiles('go.sum', 'web/package.lock') }} - restore-keys: ${{ runner.os }}-ntfy- + uses: actions/checkout@v3 - name: Install dependencies run: make build-deps-ubuntu From 1761ec0207a392a62c97cf8d7eb24b1450d938fb Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Tue, 23 May 2023 20:52:56 +0200 Subject: [PATCH 49/97] Move `react-scripts` to `devDependencies` --- web/package-lock.json | 1894 +++++++++++++++++++++++++++++++++++------ web/package.json | 4 +- 2 files changed, 1658 insertions(+), 240 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index b978749..a233aee 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -22,15 +22,18 @@ "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", "react-router-dom": "^6.2.2", - "react-scripts": "^5.0.0", "stacktrace-gps": "^3.0.4", "stacktrace-js": "^2.0.2" + }, + "devDependencies": { + "react-scripts": "^5.0.0" } }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "engines": { "node": ">=10" }, @@ -42,6 +45,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -54,6 +58,7 @@ "version": "7.21.4", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz", "integrity": "sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==", + "devOptional": true, "dependencies": { "@babel/highlight": "^7.18.6" }, @@ -65,6 +70,7 @@ "version": "7.21.7", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.21.7.tgz", "integrity": "sha512-KYMqFYTaenzMK4yUtf4EW9wc4N9ef80FsbMtkwool5zpwl4YrT1SdWYSTRcT94KO4hannogdS+LxY7L+arP3gA==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -73,6 +79,7 @@ "version": "7.21.8", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.21.8.tgz", "integrity": "sha512-YeM22Sondbo523Sz0+CirSPnbj9bG3P0CdHcBZdqUuaeOaYEFbOLoGU7lebvGP6P5J/WE9wOn7u7C4J9HvS1xQ==", + "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.21.4", @@ -102,6 +109,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -110,6 +118,7 @@ "version": "7.21.8", "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.21.8.tgz", "integrity": "sha512-HLhI+2q+BP3sf78mFUZNCGc10KEmoUqtUT1OCdMZsN+qr4qFeLUod62/zAnF3jNQstwyasDkZnVXwfK2Bml7MQ==", + "dev": true, "dependencies": { "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", "eslint-visitor-keys": "^2.1.0", @@ -127,6 +136,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, "engines": { "node": ">=10" } @@ -135,6 +145,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -143,6 +154,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.21.5.tgz", "integrity": "sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==", + "dev": true, "dependencies": { "@babel/types": "^7.21.5", "@jridgewell/gen-mapping": "^0.3.2", @@ -157,6 +169,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz", "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==", + "dev": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -168,6 +181,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.21.5.tgz", "integrity": "sha512-uNrjKztPLkUk7bpCNC0jEKDJzzkvel/W+HguzbN8krA+LPfC1CEobJEvAvGka2A/M+ViOqXdcRL0GqPUJSjx9g==", + "dev": true, "dependencies": { "@babel/types": "^7.21.5" }, @@ -179,6 +193,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.5.tgz", "integrity": "sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==", + "dev": true, "dependencies": { "@babel/compat-data": "^7.21.5", "@babel/helper-validator-option": "^7.21.0", @@ -197,6 +212,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -205,6 +221,7 @@ "version": "7.21.8", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.21.8.tgz", "integrity": "sha512-+THiN8MqiH2AczyuZrnrKL6cAxFRRQDKW9h1YkBvbgKmAm6mwiacig1qT73DHIWMGo40GRnsEfN3LA+E6NtmSw==", + "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-environment-visitor": "^7.21.5", @@ -227,6 +244,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -235,6 +253,7 @@ "version": "7.21.8", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.21.8.tgz", "integrity": "sha512-zGuSdedkFtsFHGbexAvNuipg1hbtitDLo2XE8/uf6Y9sOQV1xsYX/2pNbtedp/X0eU1pIt+kGvaqHCowkRbS5g==", + "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.18.6", "regexpu-core": "^5.3.1", @@ -251,6 +270,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -259,6 +279,7 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz", "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==", + "dev": true, "dependencies": { "@babel/helper-compilation-targets": "^7.17.7", "@babel/helper-plugin-utils": "^7.16.7", @@ -275,6 +296,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -283,6 +305,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.21.5.tgz", "integrity": "sha512-IYl4gZ3ETsWocUWgsFZLM5i1BYx9SoemminVEXadgLBa9TdeorzgLKm8wWLA6J1N/kT3Kch8XIk1laNzYoHKvQ==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -291,6 +314,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.21.0.tgz", "integrity": "sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==", + "dev": true, "dependencies": { "@babel/template": "^7.20.7", "@babel/types": "^7.21.0" @@ -303,6 +327,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", + "dev": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -314,6 +339,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.21.5.tgz", "integrity": "sha512-nIcGfgwpH2u4n9GG1HpStW5Ogx7x7ekiFHbjjFRKXbn5zUvqO9ZgotCO4x1aNbKn/x/xOUaXEhyNHCwtFCpxWg==", + "dev": true, "dependencies": { "@babel/types": "^7.21.5" }, @@ -325,6 +351,7 @@ "version": "7.21.4", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.21.4.tgz", "integrity": "sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==", + "devOptional": true, "dependencies": { "@babel/types": "^7.21.4" }, @@ -336,6 +363,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.21.5.tgz", "integrity": "sha512-bI2Z9zBGY2q5yMHoBvJ2a9iX3ZOAzJPm7Q8Yz6YeoUjU/Cvhmi2G4QyTNyPBqqXSgTjUxRg3L0xV45HvkNWWBw==", + "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.21.5", "@babel/helper-module-imports": "^7.21.4", @@ -354,6 +382,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz", "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==", + "dev": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -365,6 +394,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.21.5.tgz", "integrity": "sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -373,6 +403,7 @@ "version": "7.18.9", "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz", "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==", + "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-environment-visitor": "^7.18.9", @@ -390,6 +421,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.21.5.tgz", "integrity": "sha512-/y7vBgsr9Idu4M6MprbOVUfH3vs7tsIfnVWv/Ml2xgwvyH6LTngdfbf5AdsKwkJy4zgy1X/kuNrEKvhhK28Yrg==", + "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.21.5", "@babel/helper-member-expression-to-functions": "^7.21.5", @@ -406,6 +438,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.21.5.tgz", "integrity": "sha512-ENPDAMC1wAjR0uaCUwliBdiSl1KBJAVnMTzXqi64c2MG8MPR6ii4qf7bSXDqSFbr4W6W028/rf5ivoHop5/mkg==", + "dev": true, "dependencies": { "@babel/types": "^7.21.5" }, @@ -417,6 +450,7 @@ "version": "7.20.0", "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.20.0.tgz", "integrity": "sha512-5y1JYeNKfvnT8sZcK9DVRtpTbGiomYIHviSP3OQWmDPU3DeH4a1ZlT/N2lyQ5P8egjcRaT/Y9aNqUxK0WsnIIg==", + "dev": true, "dependencies": { "@babel/types": "^7.20.0" }, @@ -428,6 +462,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", + "dev": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -439,6 +474,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.21.5.tgz", "integrity": "sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w==", + "devOptional": true, "engines": { "node": ">=6.9.0" } @@ -447,6 +483,7 @@ "version": "7.19.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", + "devOptional": true, "engines": { "node": ">=6.9.0" } @@ -455,6 +492,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.21.0.tgz", "integrity": "sha512-rmL/B8/f0mKS2baE9ZpyTcTavvEuWhTTW8amjzXNvYG4AwBsqTLikfXsEofsJEfKHf+HQVQbFOHy6o+4cnC/fQ==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -463,6 +501,7 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.20.5.tgz", "integrity": "sha512-bYMxIWK5mh+TgXGVqAtnu5Yn1un+v8DDZtqyzKRLUzrh70Eal2O3aZ7aPYiMADO4uKlkzOiRiZ6GX5q3qxvW9Q==", + "dev": true, "dependencies": { "@babel/helper-function-name": "^7.19.0", "@babel/template": "^7.18.10", @@ -477,6 +516,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.21.5.tgz", "integrity": "sha512-BSY+JSlHxOmGsPTydUkPf1MdMQ3M81x5xGCOVgWM3G8XH77sJ292Y2oqcp0CbbgxhqBuI46iUz1tT7hqP7EfgA==", + "dev": true, "dependencies": { "@babel/template": "^7.20.7", "@babel/traverse": "^7.21.5", @@ -490,6 +530,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "devOptional": true, "dependencies": { "@babel/helper-validator-identifier": "^7.18.6", "chalk": "^2.0.0", @@ -503,6 +544,7 @@ "version": "7.21.8", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.21.8.tgz", "integrity": "sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA==", + "dev": true, "bin": { "parser": "bin/babel-parser.js" }, @@ -514,6 +556,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz", "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6" }, @@ -528,6 +571,7 @@ "version": "7.20.7", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.20.7.tgz", "integrity": "sha512-sbr9+wNE5aXMBBFBICk01tt7sBf2Oc9ikRFEcem/ZORup9IMUdNhW7/wVLEbbtlWOsEubJet46mHAL2C8+2jKQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", @@ -544,6 +588,7 @@ "version": "7.20.7", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.20.7.tgz", "integrity": "sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==", + "dev": true, "dependencies": { "@babel/helper-environment-visitor": "^7.18.9", "@babel/helper-plugin-utils": "^7.20.2", @@ -561,6 +606,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", + "dev": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -576,6 +622,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.21.0.tgz", "integrity": "sha512-XP5G9MWNUskFuP30IfFSEFB0Z6HzLIUcjYM4bYOPHXl7eiJ9HFv8tWj6TXTN5QODiEhDZAeI4hLok2iHFFV4hw==", + "dev": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.21.0", "@babel/helper-plugin-utils": "^7.20.2", @@ -592,6 +639,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.21.0.tgz", "integrity": "sha512-MfgX49uRrFUTL/HvWtmx3zmpyzMMr4MTj3d527MLlr/4RTT9G/ytFFP7qet2uM2Ve03b+BkpWUpK+lRXnQ+v9w==", + "dev": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.21.0", "@babel/helper-plugin-utils": "^7.20.2", @@ -610,6 +658,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz", "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-dynamic-import": "^7.8.3" @@ -625,6 +674,7 @@ "version": "7.18.9", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz", "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.9", "@babel/plugin-syntax-export-namespace-from": "^7.8.3" @@ -640,6 +690,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz", "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-json-strings": "^7.8.3" @@ -655,6 +706,7 @@ "version": "7.20.7", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.20.7.tgz", "integrity": "sha512-y7C7cZgpMIjWlKE5T7eJwp+tnRYM89HmRvWM5EQuB5BoHEONjmQ8lSNmBUwOyy/GFRsohJED51YBF79hE1djug==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" @@ -670,6 +722,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" @@ -685,6 +738,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz", "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-numeric-separator": "^7.10.4" @@ -700,6 +754,7 @@ "version": "7.20.7", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.7.tgz", "integrity": "sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==", + "dev": true, "dependencies": { "@babel/compat-data": "^7.20.5", "@babel/helper-compilation-targets": "^7.20.7", @@ -718,6 +773,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz", "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" @@ -733,6 +789,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz", "integrity": "sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0", @@ -749,6 +806,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", + "dev": true, "dependencies": { "@babel/helper-create-class-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -764,6 +822,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0.tgz", "integrity": "sha512-ha4zfehbJjc5MmXBlHec1igel5TJXXLDDRbuJ4+XT2TJcyD9/V1919BA8gMvsdHcNMBy4WBUBiRb3nw/EQUtBw==", + "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-create-class-features-plugin": "^7.21.0", @@ -781,6 +840,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz", "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==", + "dev": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -796,6 +856,7 @@ "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -807,6 +868,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -818,6 +880,7 @@ "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, @@ -829,6 +892,7 @@ "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -843,6 +907,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.21.0.tgz", "integrity": "sha512-tIoPpGBR8UuM4++ccWN3gifhVvQu7ZizuR1fklhRJrd5ewgbkUS+0KVFeWWxELtn18NTLoW32XV7zyOgIAiz+w==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2" }, @@ -857,6 +922,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -868,6 +934,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.3" }, @@ -879,6 +946,7 @@ "version": "7.21.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.21.4.tgz", "integrity": "sha512-l9xd3N+XG4fZRxEP3vXdK6RW7vN1Uf5dxzRC/09wV86wqZ/YYQooBIGNsiRdfNR3/q2/5pPzV4B54J/9ctX5jw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2" }, @@ -893,6 +961,7 @@ "version": "7.20.0", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.20.0.tgz", "integrity": "sha512-IUh1vakzNoWalR8ch/areW7qFopR2AEw03JlG7BbrDqmQ4X3q9uuipQwSGrUn7oGiemKjtSLDhNtQHzMHr1JdQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.19.0" }, @@ -907,6 +976,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -918,6 +988,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -929,6 +1000,7 @@ "version": "7.21.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.21.4.tgz", "integrity": "sha512-5hewiLct5OKyh6PLKEYaFclcqtIgCb6bmELouxjF6up5q3Sov7rOayW4RwhbaBL0dit8rA80GNfY+UuDp2mBbQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2" }, @@ -943,6 +1015,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -954,6 +1027,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -965,6 +1039,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -976,6 +1051,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -987,6 +1063,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -998,6 +1075,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -1009,6 +1087,7 @@ "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -1023,6 +1102,7 @@ "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -1037,6 +1117,7 @@ "version": "7.21.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.21.4.tgz", "integrity": "sha512-xz0D39NvhQn4t4RNsHmDnnsaQizIlUkdtYvLs8La1BlfjQ6JEwxkJGeqJMW2tAXx+q6H+WFuUTXNdYVpEya0YA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2" }, @@ -1051,6 +1132,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.21.5.tgz", "integrity": "sha512-wb1mhwGOCaXHDTcsRYMKF9e5bbMgqwxtqa2Y1ifH96dXJPwbuLX9qHy3clhrxVqgMz7nyNXs8VkxdH8UBcjKqA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.21.5" }, @@ -1065,6 +1147,7 @@ "version": "7.20.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.20.7.tgz", "integrity": "sha512-Uo5gwHPT9vgnSXQxqGtpdufUiWp96gk7yiP4Mp5bm1QMkEmLXBO7PAGYbKoJ6DhAwiNkcHFBol/x5zZZkL/t0Q==", + "dev": true, "dependencies": { "@babel/helper-module-imports": "^7.18.6", "@babel/helper-plugin-utils": "^7.20.2", @@ -1081,6 +1164,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz", "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6" }, @@ -1095,6 +1179,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.21.0.tgz", "integrity": "sha512-Mdrbunoh9SxwFZapeHVrwFmri16+oYotcZysSzhNIVDwIAb1UV+kvnxULSYq9J3/q5MDG+4X6w8QVgD1zhBXNQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2" }, @@ -1109,6 +1194,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.21.0.tgz", "integrity": "sha512-RZhbYTCEUAe6ntPehC4hlslPWosNHDox+vAs4On/mCLRLfoDVHf6hVEd7kuxr1RnHwJmxFfUM3cZiZRmPxJPXQ==", + "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-compilation-targets": "^7.20.7", @@ -1131,6 +1217,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.21.5.tgz", "integrity": "sha512-TR653Ki3pAwxBxUe8srfF3e4Pe3FTA46uaNHYyQwIoM4oWKSoOZiDNyHJ0oIoDIUPSRQbQG7jzgVBX3FPVne1Q==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.21.5", "@babel/template": "^7.20.7" @@ -1146,6 +1233,7 @@ "version": "7.21.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.21.3.tgz", "integrity": "sha512-bp6hwMFzuiE4HqYEyoGJ/V2LeIWn+hLVKc4pnj++E5XQptwhtcGmSayM029d/j2X1bPKGTlsyPwAubuU22KhMA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2" }, @@ -1160,6 +1248,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz", "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==", + "dev": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -1175,6 +1264,7 @@ "version": "7.18.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz", "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.9" }, @@ -1189,6 +1279,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz", "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==", + "dev": true, "dependencies": { "@babel/helper-builder-binary-assignment-operator-visitor": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -1204,6 +1295,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.21.0.tgz", "integrity": "sha512-FlFA2Mj87a6sDkW4gfGrQQqwY/dLlBAyJa2dJEZ+FHXUVHBflO2wyKvg+OOEzXfrKYIa4HWl0mgmbCzt0cMb7w==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/plugin-syntax-flow": "^7.18.6" @@ -1219,6 +1311,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.21.5.tgz", "integrity": "sha512-nYWpjKW/7j/I/mZkGVgHJXh4bA1sfdFnJoOXwJuj4m3Q2EraO/8ZyrkCau9P5tbHQk01RMSt6KYLCsW7730SXQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.21.5" }, @@ -1233,6 +1326,7 @@ "version": "7.18.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz", "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==", + "dev": true, "dependencies": { "@babel/helper-compilation-targets": "^7.18.9", "@babel/helper-function-name": "^7.18.9", @@ -1249,6 +1343,7 @@ "version": "7.18.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz", "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.9" }, @@ -1263,6 +1358,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz", "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6" }, @@ -1277,6 +1373,7 @@ "version": "7.20.11", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.20.11.tgz", "integrity": "sha512-NuzCt5IIYOW0O30UvqktzHYR2ud5bOWbY0yaxWZ6G+aFzOMJvrs5YHNikrbdaT15+KNO31nPOy5Fim3ku6Zb5g==", + "dev": true, "dependencies": { "@babel/helper-module-transforms": "^7.20.11", "@babel/helper-plugin-utils": "^7.20.2" @@ -1292,6 +1389,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.21.5.tgz", "integrity": "sha512-OVryBEgKUbtqMoB7eG2rs6UFexJi6Zj6FDXx+esBLPTCxCNxAY9o+8Di7IsUGJ+AVhp5ncK0fxWUBd0/1gPhrQ==", + "dev": true, "dependencies": { "@babel/helper-module-transforms": "^7.21.5", "@babel/helper-plugin-utils": "^7.21.5", @@ -1308,6 +1406,7 @@ "version": "7.20.11", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.20.11.tgz", "integrity": "sha512-vVu5g9BPQKSFEmvt2TA4Da5N+QVS66EX21d8uoOihC+OCpUoGvzVsXeqFdtAEfVa5BILAeFt+U7yVmLbQnAJmw==", + "dev": true, "dependencies": { "@babel/helper-hoist-variables": "^7.18.6", "@babel/helper-module-transforms": "^7.20.11", @@ -1325,6 +1424,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz", "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==", + "dev": true, "dependencies": { "@babel/helper-module-transforms": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -1340,6 +1440,7 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.20.5.tgz", "integrity": "sha512-mOW4tTzi5iTLnw+78iEq3gr8Aoq4WNRGpmSlrogqaiCBoR1HFhpU4JkpQFOHfeYx3ReVIFWOQJS4aZBRvuZ6mA==", + "dev": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.20.5", "@babel/helper-plugin-utils": "^7.20.2" @@ -1355,6 +1456,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz", "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6" }, @@ -1369,6 +1471,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz", "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/helper-replace-supers": "^7.18.6" @@ -1384,6 +1487,7 @@ "version": "7.21.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.21.3.tgz", "integrity": "sha512-Wxc+TvppQG9xWFYatvCGPvZ6+SIUxQ2ZdiBP+PHYMIjnPXD+uThCshaz4NZOnODAtBjjcVQQ/3OKs9LW28purQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2" }, @@ -1398,6 +1502,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz", "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6" }, @@ -1412,6 +1517,7 @@ "version": "7.21.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.21.3.tgz", "integrity": "sha512-4DVcFeWe/yDYBLp0kBmOGFJ6N2UYg7coGid1gdxb4co62dy/xISDMaYBXBVXEDhfgMk7qkbcYiGtwd5Q/hwDDQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2" }, @@ -1426,6 +1532,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.18.6.tgz", "integrity": "sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6" }, @@ -1440,6 +1547,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.21.5.tgz", "integrity": "sha512-ELdlq61FpoEkHO6gFRpfj0kUgSwQTGoaEU8eMRoS8Dv3v6e7BjEAj5WMtIBRdHUeAioMhKP5HyxNzNnP+heKbA==", + "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-module-imports": "^7.21.4", @@ -1458,6 +1566,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.18.6.tgz", "integrity": "sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==", + "dev": true, "dependencies": { "@babel/plugin-transform-react-jsx": "^7.18.6" }, @@ -1472,6 +1581,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.18.6.tgz", "integrity": "sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==", + "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -1487,6 +1597,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.21.5.tgz", "integrity": "sha512-ZoYBKDb6LyMi5yCsByQ5jmXsHAQDDYeexT1Szvlmui+lADvfSecr5Dxd/PkrTC3pAD182Fcju1VQkB4oCp9M+w==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.21.5", "regenerator-transform": "^0.15.1" @@ -1502,6 +1613,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz", "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6" }, @@ -1516,6 +1628,7 @@ "version": "7.21.4", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.21.4.tgz", "integrity": "sha512-1J4dhrw1h1PqnNNpzwxQ2UBymJUF8KuPjAAnlLwZcGhHAIqUigFW7cdK6GHoB64ubY4qXQNYknoUeks4Wz7CUA==", + "dev": true, "dependencies": { "@babel/helper-module-imports": "^7.21.4", "@babel/helper-plugin-utils": "^7.20.2", @@ -1535,6 +1648,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -1543,6 +1657,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz", "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6" }, @@ -1557,6 +1672,7 @@ "version": "7.20.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.20.7.tgz", "integrity": "sha512-ewBbHQ+1U/VnH1fxltbJqDeWBU1oNLG8Dj11uIv3xVf7nrQu0bPGe5Rf716r7K5Qz+SqtAOVswoVunoiBtGhxw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.20.2", "@babel/helper-skip-transparent-expression-wrappers": "^7.20.0" @@ -1572,6 +1688,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz", "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6" }, @@ -1586,6 +1703,7 @@ "version": "7.18.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz", "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.9" }, @@ -1600,6 +1718,7 @@ "version": "7.18.9", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz", "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.9" }, @@ -1614,6 +1733,7 @@ "version": "7.21.3", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.21.3.tgz", "integrity": "sha512-RQxPz6Iqt8T0uw/WsJNReuBpWpBqs/n7mNo18sKLoTbMp+UrEekhH+pKSVC7gWz+DNjo9gryfV8YzCiT45RgMw==", + "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.18.6", "@babel/helper-create-class-features-plugin": "^7.21.0", @@ -1631,6 +1751,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.21.5.tgz", "integrity": "sha512-LYm/gTOwZqsYohlvFUe/8Tujz75LqqVC2w+2qPHLR+WyWHGCZPN1KBpJCJn+4Bk4gOkQy/IXKIge6az5MqwlOg==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.21.5" }, @@ -1645,6 +1766,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz", "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==", + "dev": true, "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" @@ -1660,6 +1782,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.21.5.tgz", "integrity": "sha512-wH00QnTTldTbf/IefEVyChtRdw5RJvODT/Vb4Vcxq1AZvtXj6T0YeX0cAcXhI6/BdGuiP3GcNIL4OQbI2DVNxg==", + "dev": true, "dependencies": { "@babel/compat-data": "^7.21.5", "@babel/helper-compilation-targets": "^7.21.5", @@ -1749,6 +1872,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -1757,6 +1881,7 @@ "version": "0.1.5", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz", "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", @@ -1772,6 +1897,7 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.18.6.tgz", "integrity": "sha512-zXr6atUmyYdiWRVLOZahakYmOBHtWc2WGCkP8PYTgZi0iJXDY2CN180TdrIW4OGOAdLc7TifzDIvtx6izaRIzg==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.18.6", "@babel/helper-validator-option": "^7.18.6", @@ -1791,6 +1917,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.21.5.tgz", "integrity": "sha512-iqe3sETat5EOrORXiQ6rWfoOg2y68Cs75B9wNxdPW4kixJxh7aXQE1KPdWLDniC24T/6dSnguF33W9j/ZZQcmA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.21.5", "@babel/helper-validator-option": "^7.21.0", @@ -1808,7 +1935,8 @@ "node_modules/@babel/regjsgen": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" + "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", + "dev": true }, "node_modules/@babel/runtime": { "version": "7.21.5", @@ -1825,6 +1953,7 @@ "version": "7.20.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.20.7.tgz", "integrity": "sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.18.6", "@babel/parser": "^7.20.7", @@ -1838,6 +1967,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.21.5.tgz", "integrity": "sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.21.4", "@babel/generator": "^7.21.5", @@ -1858,6 +1988,7 @@ "version": "7.21.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.5.tgz", "integrity": "sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==", + "devOptional": true, "dependencies": { "@babel/helper-string-parser": "^7.21.5", "@babel/helper-validator-identifier": "^7.19.1", @@ -1870,17 +2001,20 @@ "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==" + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true }, "node_modules/@csstools/normalize.css": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.0.0.tgz", - "integrity": "sha512-M0qqxAcwCsIVfpFQSlGN5XjXWu8l5JDZN+fPt1LeW5SZexQTgnaEvgXAY+CeygRw0EeppWHi12JxESWiWrB0Sg==" + "integrity": "sha512-M0qqxAcwCsIVfpFQSlGN5XjXWu8l5JDZN+fPt1LeW5SZexQTgnaEvgXAY+CeygRw0EeppWHi12JxESWiWrB0Sg==", + "dev": true }, "node_modules/@csstools/postcss-cascade-layers": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-1.1.1.tgz", "integrity": "sha512-+KdYrpKC5TgomQr2DlZF4lDEpHcoxnj5IGddYYfBWJAKfj1JtuHUIqMa+E1pJJ+z3kvDViWMqyqPlG4Ja7amQA==", + "dev": true, "dependencies": { "@csstools/selector-specificity": "^2.0.2", "postcss-selector-parser": "^6.0.10" @@ -1900,6 +2034,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-1.1.1.tgz", "integrity": "sha512-Bc0f62WmHdtRDjf5f3e2STwRAl89N2CLb+9iAwzrv4L2hncrbDwnQD9PCq0gtAt7pOI2leIV08HIBUd4jxD8cw==", + "dev": true, "dependencies": { "@csstools/postcss-progressive-custom-properties": "^1.1.0", "postcss-value-parser": "^4.2.0" @@ -1919,6 +2054,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-1.0.1.tgz", "integrity": "sha512-ZgrlzuUAjXIOc2JueK0X5sZDjCtgimVp/O5CEqTcs5ShWBa6smhWYbS0x5cVc/+rycTDbjjzoP0KTDnUneZGOg==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -1937,6 +2073,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-1.0.2.tgz", "integrity": "sha512-YHdEru4o3Rsbjmu6vHy4UKOXZD+Rn2zmkAmLRfPet6+Jz4Ojw8cbWxe1n42VaXQhD3CQUXXTooIy8OkVbUcL+w==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -1955,6 +2092,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-1.0.1.tgz", "integrity": "sha512-Ot1rcwRAaRHNKC9tAqoqNZhjdYBzKk1POgWfhN4uCOE47ebGcLRqXjKkApVDpjifL6u2/55ekkpnFcp+s/OZUw==", + "dev": true, "dependencies": { "@csstools/postcss-progressive-custom-properties": "^1.1.0", "postcss-value-parser": "^4.2.0" @@ -1974,6 +2112,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-2.0.7.tgz", "integrity": "sha512-7JPeVVZHd+jxYdULl87lvjgvWldYu+Bc62s9vD/ED6/QTGjy0jy0US/f6BG53sVMTBJ1lzKZFpYmofBN9eaRiA==", + "dev": true, "dependencies": { "@csstools/selector-specificity": "^2.0.0", "postcss-selector-parser": "^6.0.10" @@ -1993,6 +2132,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-1.0.0.tgz", "integrity": "sha512-JCsQsw1wjYwv1bJmgjKSoZNvf7R6+wuHDAbi5f/7MbFhl2d/+v+TvBTU4BJH3G1X1H87dHl0mh6TfYogbT/dJQ==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -2011,6 +2151,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-1.0.1.tgz", "integrity": "sha512-jcOanIbv55OFKQ3sYeFD/T0Ti7AMXc9nM1hZWu8m/2722gOTxFg7xYu4RDLJLeZmPUVQlGzo4jhzvTUq3x4ZUw==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -2029,6 +2170,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-1.1.1.tgz", "integrity": "sha512-nJpJgsdA3dA9y5pgyb/UfEzE7W5Ka7u0CX0/HIMVBNWzWemdcTH3XwANECU6anWv/ao4vVNLTMxhiPNZsTK6iA==", + "dev": true, "dependencies": { "@csstools/postcss-progressive-custom-properties": "^1.1.0", "postcss-value-parser": "^4.2.0" @@ -2048,6 +2190,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-1.3.0.tgz", "integrity": "sha512-ASA9W1aIy5ygskZYuWams4BzafD12ULvSypmaLJT2jvQ8G0M3I8PRQhC0h7mG0Z3LI05+agZjqSR9+K9yaQQjA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -2062,6 +2205,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-1.0.1.tgz", "integrity": "sha512-dz0LNoo3ijpTOQqEJLY8nyaapl6umbmDcgj4AD0lgVQ572b2eqA1iGZYTTWhrcrHztWDDRAX2DGYyw2VBjvCvQ==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -2080,6 +2224,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-1.0.0.tgz", "integrity": "sha512-c1XwKJ2eMIWrzQenN0XbcfzckOLLJiczqy+YvfGmzoVXd7pT9FfObiSEfzs84bpE/VqfpEuAZ9tCRbZkZxxbdw==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -2098,6 +2243,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-1.0.2.tgz", "integrity": "sha512-woKaLO///4bb+zZC2s80l+7cm07M7268MsyG3M0ActXXEFi6SuhvriQYcb58iiKGbjwwIU7n45iRLEHypB47Og==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -2116,6 +2262,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-1.0.2.tgz", "integrity": "sha512-c8J4roPBILnelAsdLr4XOAR/GsTm0GJi4XpcfvoWk3U6KiTCqiFYc63KhRMQQX35jYMp4Ao8Ij9+IZRgMfJp1g==", + "dev": true, "engines": { "node": "^12 || ^14 || >=16" }, @@ -2131,6 +2278,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.2.0.tgz", "integrity": "sha512-+OJ9konv95ClSTOJCmMZqpd5+YGsB2S+x6w3E1oaM8UuR5j8nTNHYSz8c9BEPGDOCMQYIEEGlVPj/VY64iTbGw==", + "dev": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -2293,6 +2441,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, "dependencies": { "eslint-visitor-keys": "^3.3.0" }, @@ -2307,6 +2456,7 @@ "version": "4.5.1", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", + "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -2315,6 +2465,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", + "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -2336,12 +2487,14 @@ "node_modules/@eslint/eslintrc/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "dev": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -2356,6 +2509,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -2367,6 +2521,7 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, "engines": { "node": ">=10" }, @@ -2378,6 +2533,7 @@ "version": "8.40.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz", "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==", + "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -2386,6 +2542,7 @@ "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", + "dev": true, "dependencies": { "@humanwhocodes/object-schema": "^1.2.1", "debug": "^4.1.1", @@ -2399,6 +2556,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, "engines": { "node": ">=12.22" }, @@ -2410,12 +2568,14 @@ "node_modules/@humanwhocodes/object-schema": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==" + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", @@ -2431,6 +2591,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, "engines": { "node": ">=6" } @@ -2439,6 +2600,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -2451,6 +2613,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -2462,6 +2625,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "dependencies": { "p-try": "^2.0.0" }, @@ -2476,6 +2640,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -2487,6 +2652,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, "engines": { "node": ">=8" } @@ -2495,6 +2661,7 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, "engines": { "node": ">=8" } @@ -2503,6 +2670,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "dev": true, "dependencies": { "@jest/types": "^27.5.1", "@types/node": "*", @@ -2519,6 +2687,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2533,6 +2702,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2548,6 +2718,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2558,12 +2729,14 @@ "node_modules/@jest/console/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/@jest/console/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -2572,6 +2745,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -2583,6 +2757,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", + "dev": true, "dependencies": { "@jest/console": "^27.5.1", "@jest/reporters": "^27.5.1", @@ -2629,6 +2804,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2643,6 +2819,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2658,6 +2835,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2668,12 +2846,14 @@ "node_modules/@jest/core/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/@jest/core/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -2682,6 +2862,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -2693,6 +2874,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", + "dev": true, "dependencies": { "@jest/fake-timers": "^27.5.1", "@jest/types": "^27.5.1", @@ -2707,6 +2889,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "dev": true, "dependencies": { "@jest/types": "^27.5.1", "@sinonjs/fake-timers": "^8.0.1", @@ -2723,6 +2906,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", + "dev": true, "dependencies": { "@jest/environment": "^27.5.1", "@jest/types": "^27.5.1", @@ -2736,6 +2920,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", + "dev": true, "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "^27.5.1", @@ -2779,6 +2964,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2793,6 +2979,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2808,6 +2995,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2818,12 +3006,14 @@ "node_modules/@jest/reporters/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/@jest/reporters/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -2832,6 +3022,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -2840,6 +3031,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -2851,6 +3043,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", + "dev": true, "dependencies": { "@sinclair/typebox": "^0.24.1" }, @@ -2862,6 +3055,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "dev": true, "dependencies": { "callsites": "^3.0.0", "graceful-fs": "^4.2.9", @@ -2875,6 +3069,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -2883,6 +3078,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "dev": true, "dependencies": { "@jest/console": "^27.5.1", "@jest/types": "^27.5.1", @@ -2897,6 +3093,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", + "dev": true, "dependencies": { "@jest/test-result": "^27.5.1", "graceful-fs": "^4.2.9", @@ -2911,6 +3108,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "dev": true, "dependencies": { "@babel/core": "^7.1.0", "@jest/types": "^27.5.1", @@ -2936,6 +3134,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -2950,6 +3149,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2965,6 +3165,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2975,12 +3176,14 @@ "node_modules/@jest/transform/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/@jest/transform/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -2989,6 +3192,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -2997,6 +3201,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -3008,6 +3213,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", @@ -3023,6 +3229,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -3037,6 +3244,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3052,6 +3260,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -3062,12 +3271,14 @@ "node_modules/@jest/types/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/@jest/types/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -3076,6 +3287,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -3087,6 +3299,7 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, "dependencies": { "@jridgewell/set-array": "^1.0.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -3100,6 +3313,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "dev": true, "engines": { "node": ">=6.0.0" } @@ -3108,6 +3322,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, "engines": { "node": ">=6.0.0" } @@ -3116,6 +3331,7 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.3.tgz", "integrity": "sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==", + "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -3124,12 +3340,14 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.18", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "dev": true, "dependencies": { "@jridgewell/resolve-uri": "3.1.0", "@jridgewell/sourcemap-codec": "1.4.14" @@ -3138,12 +3356,14 @@ "node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.14", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", + "dev": true }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", - "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==" + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", + "dev": true }, "node_modules/@mui/base": { "version": "5.0.0-beta.1", @@ -3390,6 +3610,7 @@ "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, "dependencies": { "eslint-scope": "5.1.1" } @@ -3398,6 +3619,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -3410,6 +3632,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, "engines": { "node": ">=4.0" } @@ -3418,6 +3641,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -3430,6 +3654,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "engines": { "node": ">= 8" } @@ -3438,6 +3663,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -3450,6 +3676,7 @@ "version": "0.5.10", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz", "integrity": "sha512-j0Ya0hCFZPd4x40qLzbhGsh9TMtdb+CJQiso+WxLOPNasohq9cc5SNUcwsZaRH6++Xh91Xkm/xHCkuIiIu0LUA==", + "dev": true, "dependencies": { "ansi-html-community": "^0.0.8", "common-path-prefix": "^3.0.0", @@ -3499,6 +3726,7 @@ "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, "engines": { "node": ">= 8" } @@ -3524,6 +3752,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", + "dev": true, "dependencies": { "@babel/helper-module-imports": "^7.10.4", "@rollup/pluginutils": "^3.1.0" @@ -3546,6 +3775,7 @@ "version": "11.2.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", + "dev": true, "dependencies": { "@rollup/pluginutils": "^3.1.0", "@types/resolve": "1.17.1", @@ -3565,6 +3795,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", + "dev": true, "dependencies": { "@rollup/pluginutils": "^3.1.0", "magic-string": "^0.25.7" @@ -3577,6 +3808,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, "dependencies": { "@types/estree": "0.0.39", "estree-walker": "^1.0.1", @@ -3592,22 +3824,26 @@ "node_modules/@rollup/pluginutils/node_modules/@types/estree": { "version": "0.0.39", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==" + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true }, "node_modules/@rushstack/eslint-patch": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", - "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==" + "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==", + "dev": true }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", - "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==" + "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", + "dev": true }, "node_modules/@sinonjs/commons": { "version": "1.8.6", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, "dependencies": { "type-detect": "4.0.8" } @@ -3616,6 +3852,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "dev": true, "dependencies": { "@sinonjs/commons": "^1.7.0" } @@ -3624,6 +3861,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", + "dev": true, "dependencies": { "ejs": "^3.1.6", "json5": "^2.2.0", @@ -3635,6 +3873,7 @@ "version": "5.4.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", "integrity": "sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg==", + "dev": true, "engines": { "node": ">=10" }, @@ -3647,6 +3886,7 @@ "version": "5.4.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz", "integrity": "sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg==", + "dev": true, "engines": { "node": ">=10" }, @@ -3659,6 +3899,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz", "integrity": "sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA==", + "dev": true, "engines": { "node": ">=10" }, @@ -3671,6 +3912,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz", "integrity": "sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ==", + "dev": true, "engines": { "node": ">=10" }, @@ -3683,6 +3925,7 @@ "version": "5.4.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz", "integrity": "sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg==", + "dev": true, "engines": { "node": ">=10" }, @@ -3695,6 +3938,7 @@ "version": "5.4.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz", "integrity": "sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw==", + "dev": true, "engines": { "node": ">=10" }, @@ -3707,6 +3951,7 @@ "version": "5.4.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz", "integrity": "sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q==", + "dev": true, "engines": { "node": ">=10" }, @@ -3719,6 +3964,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz", "integrity": "sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ==", + "dev": true, "engines": { "node": ">=10" }, @@ -3731,6 +3977,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz", "integrity": "sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig==", + "dev": true, "dependencies": { "@svgr/babel-plugin-add-jsx-attribute": "^5.4.0", "@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0", @@ -3753,6 +4000,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz", "integrity": "sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ==", + "dev": true, "dependencies": { "@svgr/plugin-jsx": "^5.5.0", "camelcase": "^6.2.0", @@ -3770,6 +4018,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz", "integrity": "sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ==", + "dev": true, "dependencies": { "@babel/types": "^7.12.6" }, @@ -3785,6 +4034,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz", "integrity": "sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA==", + "dev": true, "dependencies": { "@babel/core": "^7.12.3", "@svgr/babel-preset": "^5.5.0", @@ -3803,6 +4053,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.5.0.tgz", "integrity": "sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ==", + "dev": true, "dependencies": { "cosmiconfig": "^7.0.0", "deepmerge": "^4.2.2", @@ -3820,6 +4071,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-5.5.0.tgz", "integrity": "sha512-DOBOK255wfQxguUta2INKkzPj6AIS6iafZYiYmHn6W3pHlycSRRlvWKCfLDG10fXfLWqE3DJHgRUOyJYmARa7g==", + "dev": true, "dependencies": { "@babel/core": "^7.12.3", "@babel/plugin-transform-react-constant-elements": "^7.12.1", @@ -3842,6 +4094,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true, "engines": { "node": ">= 6" } @@ -3850,6 +4103,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, "engines": { "node": ">=10.13.0" } @@ -3858,6 +4112,7 @@ "version": "7.20.0", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz", "integrity": "sha512-+n8dL/9GWblDO0iU6eZAwEIJVr5DWigtle+Q6HLOrh/pdbXOhOtqzq8VPPE2zvNJzSKY4vH/z3iT3tn0A3ypiQ==", + "dev": true, "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -3870,6 +4125,7 @@ "version": "7.6.4", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", + "dev": true, "dependencies": { "@babel/types": "^7.0.0" } @@ -3878,6 +4134,7 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", + "dev": true, "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" @@ -3887,6 +4144,7 @@ "version": "7.18.5", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.18.5.tgz", "integrity": "sha512-enCvTL8m/EHS/zIvJno9nE+ndYPh1/oNFzRYRmtUqJICG2VnCSBzMLW5VN2KCQU91f23tsNKR8v7VJJQMatl7Q==", + "dev": true, "dependencies": { "@babel/types": "^7.3.0" } @@ -3895,6 +4153,7 @@ "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -3904,6 +4163,7 @@ "version": "3.5.10", "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.10.tgz", "integrity": "sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -3912,6 +4172,7 @@ "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -3920,6 +4181,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz", "integrity": "sha512-4x5FkPpLipqwthjPsF7ZRbOv3uoLUFkTA9G9v583qi4pACvq0uTELrB8OLUzPWUI4IJIyvM85vzkV1nyiI2Lig==", + "dev": true, "dependencies": { "@types/express-serve-static-core": "*", "@types/node": "*" @@ -3929,6 +4191,7 @@ "version": "8.37.0", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.37.0.tgz", "integrity": "sha512-Piet7dG2JBuDIfohBngQ3rCt7MgO9xCO4xIMKxBThCq5PNRB91IjlJ10eJVwfoNtvTErmxLzwBZ7rHZtbOMmFQ==", + "dev": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -3938,6 +4201,7 @@ "version": "3.7.4", "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", + "dev": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -3946,12 +4210,14 @@ "node_modules/@types/estree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", - "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==" + "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "dev": true }, "node_modules/@types/express": { "version": "4.17.17", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", + "dev": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -3963,6 +4229,7 @@ "version": "4.17.35", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz", "integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==", + "dev": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -3974,6 +4241,7 @@ "version": "4.1.6", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", "integrity": "sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -3981,12 +4249,14 @@ "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==" + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "dev": true }, "node_modules/@types/http-proxy": { "version": "1.17.11", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.11.tgz", "integrity": "sha512-HC8G7c1WmaF2ekqpnFq626xd3Zz0uvaqFmBJNRZCGEZCXkvSdJoNFn/8Ygbd9fKNQj8UzLdCETaI0UWPAjK7IA==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -3994,12 +4264,14 @@ "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==" + "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", + "dev": true }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "dev": true, "dependencies": { "@types/istanbul-lib-coverage": "*" } @@ -4008,6 +4280,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "dev": true, "dependencies": { "@types/istanbul-lib-report": "*" } @@ -4015,32 +4288,38 @@ "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", - "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" + "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", + "dev": true }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", - "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", + "dev": true }, "node_modules/@types/node": { "version": "20.2.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.2.1.tgz", - "integrity": "sha512-DqJociPbZP1lbZ5SQPk4oag6W7AyaGMO6gSfRwq3PWl4PXTwJpRQJhDq4W0kzrg3w6tJ1SwlvGZ5uKFHY13LIg==" + "integrity": "sha512-DqJociPbZP1lbZ5SQPk4oag6W7AyaGMO6gSfRwq3PWl4PXTwJpRQJhDq4W0kzrg3w6tJ1SwlvGZ5uKFHY13LIg==", + "dev": true }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", - "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", + "devOptional": true }, "node_modules/@types/prettier": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.2.tgz", - "integrity": "sha512-KufADq8uQqo1pYKVIYzfKbJfBAc0sOeXqGbFaSpv8MRmC/zXgowNZmFcbngndGk922QDmOASEXUZCaY48gs4cg==" + "integrity": "sha512-KufADq8uQqo1pYKVIYzfKbJfBAc0sOeXqGbFaSpv8MRmC/zXgowNZmFcbngndGk922QDmOASEXUZCaY48gs4cg==", + "dev": true }, "node_modules/@types/prop-types": { "version": "15.7.5", @@ -4050,17 +4329,20 @@ "node_modules/@types/q": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.5.tgz", - "integrity": "sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ==" + "integrity": "sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ==", + "dev": true }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true }, "node_modules/@types/range-parser": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", - "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true }, "node_modules/@types/react": { "version": "18.2.6", @@ -4092,6 +4374,7 @@ "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -4099,7 +4382,8 @@ "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true }, "node_modules/@types/scheduler": { "version": "0.16.3", @@ -4109,12 +4393,14 @@ "node_modules/@types/semver": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", - "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==" + "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", + "dev": true }, "node_modules/@types/send": { "version": "0.17.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.1.tgz", "integrity": "sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==", + "dev": true, "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -4124,6 +4410,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.1.tgz", "integrity": "sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg==", + "dev": true, "dependencies": { "@types/express": "*" } @@ -4132,6 +4419,7 @@ "version": "1.15.1", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.1.tgz", "integrity": "sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ==", + "dev": true, "dependencies": { "@types/mime": "*", "@types/node": "*" @@ -4141,6 +4429,7 @@ "version": "0.3.33", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", "integrity": "sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -4148,17 +4437,20 @@ "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" + "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", + "dev": true }, "node_modules/@types/trusted-types": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", - "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==" + "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==", + "dev": true }, "node_modules/@types/ws": { "version": "8.5.4", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", "integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -4167,6 +4459,7 @@ "version": "16.0.5", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.5.tgz", "integrity": "sha512-AxO/ADJOBFJScHbWhq2xAhlWP24rY4aCEG/NFaMvbT3X2MgRsLjhjQwsn0Zi5zn0LG9jUhCCZMeX9Dkuw6k+vQ==", + "dev": true, "dependencies": { "@types/yargs-parser": "*" } @@ -4174,12 +4467,14 @@ "node_modules/@types/yargs-parser": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", - "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" + "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", + "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.59.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.6.tgz", "integrity": "sha512-sXtOgJNEuRU5RLwPUb1jxtToZbgvq3M6FPpY4QENxoOggK+UpTxUBpj6tD8+Qh2g46Pi9We87E+eHnUw8YcGsw==", + "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.59.6", @@ -4213,6 +4508,7 @@ "version": "5.59.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.59.6.tgz", "integrity": "sha512-UIVfEaaHggOuhgqdpFlFQ7IN9UFMCiBR/N7uPBUyUlwNdJzYfAu9m4wbOj0b59oI/HSPW1N63Q7lsvfwTQY13w==", + "dev": true, "dependencies": { "@typescript-eslint/utils": "5.59.6" }, @@ -4231,6 +4527,7 @@ "version": "5.59.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.6.tgz", "integrity": "sha512-7pCa6al03Pv1yf/dUg/s1pXz/yGMUBAw5EeWqNTFiSueKvRNonze3hma3lhdsOrQcaOXhbk5gKu2Fludiho9VA==", + "dev": true, "dependencies": { "@typescript-eslint/scope-manager": "5.59.6", "@typescript-eslint/types": "5.59.6", @@ -4257,6 +4554,7 @@ "version": "5.59.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.6.tgz", "integrity": "sha512-gLbY3Le9Dxcb8KdpF0+SJr6EQ+hFGYFl6tVY8VxLPFDfUZC7BHFw+Vq7bM5lE9DwWPfx4vMWWTLGXgpc0mAYyQ==", + "dev": true, "dependencies": { "@typescript-eslint/types": "5.59.6", "@typescript-eslint/visitor-keys": "5.59.6" @@ -4273,6 +4571,7 @@ "version": "5.59.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.6.tgz", "integrity": "sha512-A4tms2Mp5yNvLDlySF+kAThV9VTBPCvGf0Rp8nl/eoDX9Okun8byTKoj3fJ52IJitjWOk0fKPNQhXEB++eNozQ==", + "dev": true, "dependencies": { "@typescript-eslint/typescript-estree": "5.59.6", "@typescript-eslint/utils": "5.59.6", @@ -4299,6 +4598,7 @@ "version": "5.59.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.6.tgz", "integrity": "sha512-tH5lBXZI7T2MOUgOWFdVNUILsI02shyQvfzG9EJkoONWugCG77NDDa1EeDGw7oJ5IvsTAAGVV8I3Tk2PNu9QfA==", + "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -4311,6 +4611,7 @@ "version": "5.59.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.6.tgz", "integrity": "sha512-vW6JP3lMAs/Tq4KjdI/RiHaaJSO7IUsbkz17it/Rl9Q+WkQ77EOuOnlbaU8kKfVIOJxMhnRiBG+olE7f3M16DA==", + "dev": true, "dependencies": { "@typescript-eslint/types": "5.59.6", "@typescript-eslint/visitor-keys": "5.59.6", @@ -4337,6 +4638,7 @@ "version": "5.59.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.6.tgz", "integrity": "sha512-vzaaD6EXbTS29cVH0JjXBdzMt6VBlv+hE31XktDRMX1j3462wZCJa7VzO2AxXEXcIl8GQqZPcOPuW/Z1tZVogg==", + "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", @@ -4362,6 +4664,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -4374,6 +4677,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, "engines": { "node": ">=4.0" } @@ -4382,6 +4686,7 @@ "version": "5.59.6", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.6.tgz", "integrity": "sha512-zEfbFLzB9ETcEJ4HZEEsCR9HHeNku5/Qw1jSS5McYJv5BR+ftYXwFFAH5Al+xkGaZEqowMwl7uoJjQb1YSPF8Q==", + "dev": true, "dependencies": { "@typescript-eslint/types": "5.59.6", "eslint-visitor-keys": "^3.3.0" @@ -4398,6 +4703,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "dev": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6" @@ -4406,22 +4712,26 @@ "node_modules/@webassemblyjs/floating-point-hex-parser": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==" + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", - "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==" + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "dev": true }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -4431,12 +4741,14 @@ "node_modules/@webassemblyjs/helper-wasm-bytecode": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.11.6", "@webassemblyjs/helper-buffer": "1.11.6", @@ -4448,6 +4760,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -4456,6 +4769,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -4463,12 +4777,14 @@ "node_modules/@webassemblyjs/utf8": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.11.6", "@webassemblyjs/helper-buffer": "1.11.6", @@ -4484,6 +4800,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", @@ -4496,6 +4813,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.11.6", "@webassemblyjs/helper-buffer": "1.11.6", @@ -4507,6 +4825,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.11.6", "@webassemblyjs/helper-api-error": "1.11.6", @@ -4520,6 +4839,7 @@ "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "dev": true, "dependencies": { "@webassemblyjs/ast": "1.11.6", "@xtuc/long": "4.2.2" @@ -4528,22 +4848,26 @@ "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==" + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "dev": true }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -4556,6 +4880,7 @@ "version": "8.8.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -4567,6 +4892,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dev": true, "dependencies": { "acorn": "^7.1.1", "acorn-walk": "^7.1.1" @@ -4576,6 +4902,7 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -4587,6 +4914,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, "peerDependencies": { "acorn": "^8" } @@ -4595,6 +4923,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -4603,6 +4932,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true, "engines": { "node": ">=0.4.0" } @@ -4611,6 +4941,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "dev": true, "engines": { "node": ">= 10.0.0" } @@ -4619,6 +4950,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "dev": true, "dependencies": { "loader-utils": "^2.0.0", "regex-parser": "^2.2.11" @@ -4631,6 +4963,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, "dependencies": { "debug": "4" }, @@ -4642,6 +4975,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4657,6 +4991,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, "dependencies": { "ajv": "^8.0.0" }, @@ -4673,6 +5008,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -4687,12 +5023,14 @@ "node_modules/ajv-formats/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true }, "node_modules/ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, "peerDependencies": { "ajv": "^6.9.1" } @@ -4701,6 +5039,7 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, "dependencies": { "type-fest": "^0.21.3" }, @@ -4715,6 +5054,7 @@ "version": "0.0.8", "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, "engines": [ "node >= 0.8.0" ], @@ -4726,6 +5066,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "engines": { "node": ">=8" } @@ -4734,6 +5075,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "devOptional": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -4744,12 +5086,14 @@ "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -4761,12 +5105,14 @@ "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, "dependencies": { "sprintf-js": "~1.0.2" } @@ -4775,6 +5121,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, "dependencies": { "deep-equal": "^2.0.5" } @@ -4783,6 +5130,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "is-array-buffer": "^3.0.1" @@ -4794,12 +5142,14 @@ "node_modules/array-flatten": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", - "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==" + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", + "dev": true }, "node_modules/array-includes": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.6.tgz", "integrity": "sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -4818,6 +5168,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, "engines": { "node": ">=8" } @@ -4826,6 +5177,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.1.tgz", "integrity": "sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -4843,6 +5195,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.1.tgz", "integrity": "sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -4860,6 +5213,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.5.tgz", "integrity": "sha512-kDdugMl7id9COE8R7MHF5jWk7Dqt/fs4Pv+JXoICnYwqpjjjbUurz6w5fT5IG6brLdJhv6/VoHB0H7oyIBXd+Q==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -4878,6 +5232,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.1.tgz", "integrity": "sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -4889,27 +5244,32 @@ "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true }, "node_modules/ast-types-flow": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", - "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==" + "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==", + "dev": true }, "node_modules/async": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", - "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", + "dev": true }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, "engines": { "node": ">= 4.0.0" } @@ -4918,6 +5278,7 @@ "version": "10.4.14", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", "integrity": "sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -4950,6 +5311,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -4961,6 +5323,7 @@ "version": "4.7.1", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.1.tgz", "integrity": "sha512-sCXXUhA+cljomZ3ZAwb8i1p3oOlkABzPy08ZDAoGcYuvtBPlQ1Ytde129ArXyHWDhfeewq7rlx9F+cUx2SSlkg==", + "dev": true, "engines": { "node": ">=4" } @@ -4969,6 +5332,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", "integrity": "sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==", + "dev": true, "dependencies": { "deep-equal": "^2.0.5" } @@ -4977,6 +5341,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", + "dev": true, "dependencies": { "@jest/transform": "^27.5.1", "@jest/types": "^27.5.1", @@ -4998,6 +5363,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -5012,6 +5378,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -5027,6 +5394,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -5037,12 +5405,14 @@ "node_modules/babel-jest/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/babel-jest/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -5051,6 +5421,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -5062,6 +5433,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==", + "dev": true, "dependencies": { "find-cache-dir": "^3.3.1", "loader-utils": "^2.0.0", @@ -5080,6 +5452,7 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, "dependencies": { "@types/json-schema": "^7.0.5", "ajv": "^6.12.4", @@ -5097,6 +5470,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", @@ -5112,6 +5486,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", + "dev": true, "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", @@ -5126,6 +5501,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "devOptional": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -5140,6 +5516,7 @@ "version": "0.3.8", "resolved": "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.8.tgz", "integrity": "sha512-WXiAc++qo7XcJ1ZnTYGtLxmBCVbddAml3CEXgWaBzNzLNoxtQ8AiGEFDMOhot9XjTCQbvP5E77Fj9Gk924f00Q==", + "dev": true, "peerDependencies": { "@babel/core": "^7.1.0" } @@ -5148,6 +5525,7 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz", "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==", + "dev": true, "dependencies": { "@babel/compat-data": "^7.17.7", "@babel/helper-define-polyfill-provider": "^0.3.3", @@ -5161,6 +5539,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -5169,6 +5548,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz", "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==", + "dev": true, "dependencies": { "@babel/helper-define-polyfill-provider": "^0.3.3", "core-js-compat": "^3.25.1" @@ -5181,6 +5561,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz", "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==", + "dev": true, "dependencies": { "@babel/helper-define-polyfill-provider": "^0.3.3" }, @@ -5191,12 +5572,14 @@ "node_modules/babel-plugin-transform-react-remove-prop-types": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz", - "integrity": "sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==" + "integrity": "sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==", + "dev": true }, "node_modules/babel-preset-current-node-syntax": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dev": true, "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", @@ -5219,6 +5602,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", + "dev": true, "dependencies": { "babel-plugin-jest-hoist": "^27.5.1", "babel-preset-current-node-syntax": "^1.0.0" @@ -5234,6 +5618,7 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/babel-preset-react-app/-/babel-preset-react-app-10.0.1.tgz", "integrity": "sha512-b0D9IZ1WhhCWkrTXyFuIIgqGzSkRIH5D5AmB0bXbzYAB1OBAwHcUeyWW2LorutLWF5btNo/N7r/cIdmvvKJlYg==", + "dev": true, "dependencies": { "@babel/core": "^7.16.0", "@babel/plugin-proposal-class-properties": "^7.16.0", @@ -5256,17 +5641,20 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==" + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true }, "node_modules/bfj": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.0.2.tgz", "integrity": "sha512-+e/UqUzwmzJamNF50tBV6tZPTORow7gQ96iFow+8b562OdMpEK0BcJEq2OSPEDmAbSMBQ7PKZ87ubFkgxpYWgw==", + "dev": true, "dependencies": { "bluebird": "^3.5.5", "check-types": "^11.1.1", @@ -5281,6 +5669,7 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, "engines": { "node": "*" } @@ -5289,6 +5678,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, "engines": { "node": ">=8" } @@ -5296,12 +5686,14 @@ "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dev": true, "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.4", @@ -5325,6 +5717,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, "engines": { "node": ">= 0.8" } @@ -5333,6 +5726,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "dependencies": { "ms": "2.0.0" } @@ -5341,6 +5735,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -5351,12 +5746,14 @@ "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true }, "node_modules/bonjour-service": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.1.1.tgz", "integrity": "sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg==", + "dev": true, "dependencies": { "array-flatten": "^2.1.2", "dns-equal": "^1.0.0", @@ -5367,12 +5764,14 @@ "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5382,6 +5781,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -5392,12 +5792,14 @@ "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", - "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true }, "node_modules/browserslist": { "version": "4.21.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz", "integrity": "sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -5425,6 +5827,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, "dependencies": { "node-int64": "^0.4.0" } @@ -5432,12 +5835,14 @@ "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, "engines": { "node": ">=6" }, @@ -5449,6 +5854,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, "engines": { "node": ">= 0.8" } @@ -5457,6 +5863,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, "dependencies": { "function-bind": "^1.1.1", "get-intrinsic": "^1.0.2" @@ -5469,6 +5876,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "devOptional": true, "engines": { "node": ">=6" } @@ -5477,6 +5885,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" @@ -5486,6 +5895,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, "engines": { "node": ">=10" }, @@ -5497,6 +5907,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, "engines": { "node": ">= 6" } @@ -5505,6 +5916,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, "dependencies": { "browserslist": "^4.0.0", "caniuse-lite": "^1.0.0", @@ -5516,6 +5928,7 @@ "version": "1.0.30001488", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz", "integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -5535,6 +5948,7 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==", + "dev": true, "engines": { "node": ">=4" } @@ -5543,6 +5957,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "devOptional": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -5556,6 +5971,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "devOptional": true, "engines": { "node": ">=0.8.0" } @@ -5564,6 +5980,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, "engines": { "node": ">=10" } @@ -5571,12 +5988,14 @@ "node_modules/check-types": { "version": "11.2.2", "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.2.2.tgz", - "integrity": "sha512-HBiYvXvn9Z70Z88XKjz3AEKd4HJhBXsa3j7xFnITAzoS8+q6eIGi8qDB8FKPBAjtuxjI/zFpwuiCb8oDtKOYrA==" + "integrity": "sha512-HBiYvXvn9Z70Z88XKjz3AEKd4HJhBXsa3j7xFnITAzoS8+q6eIGi8qDB8FKPBAjtuxjI/zFpwuiCb8oDtKOYrA==", + "dev": true }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, "funding": [ { "type": "individual", @@ -5603,6 +6022,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -5614,6 +6034,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, "engines": { "node": ">=6.0" } @@ -5622,6 +6043,7 @@ "version": "3.8.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", "integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", + "dev": true, "funding": [ { "type": "github", @@ -5635,12 +6057,14 @@ "node_modules/cjs-module-lexer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", - "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==" + "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", + "dev": true }, "node_modules/clean-css": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz", "integrity": "sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==", + "dev": true, "dependencies": { "source-map": "~0.6.0" }, @@ -5652,6 +6076,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -5660,6 +6085,7 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -5678,6 +6104,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, "engines": { "iojs": ">= 1.0.0", "node": ">= 0.12.0" @@ -5687,6 +6114,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz", "integrity": "sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==", + "dev": true, "dependencies": { "@types/q": "^1.5.1", "chalk": "^2.4.1", @@ -5699,12 +6127,14 @@ "node_modules/collect-v8-coverage": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", - "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==" + "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==", + "dev": true }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "devOptional": true, "dependencies": { "color-name": "1.1.3" } @@ -5712,22 +6142,26 @@ "node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "devOptional": true }, "node_modules/colord": { "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", - "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -5739,6 +6173,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, "engines": { "node": ">= 12" } @@ -5746,12 +6181,14 @@ "node_modules/common-path-prefix": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==" + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true }, "node_modules/common-tags": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, "engines": { "node": ">=4.0.0" } @@ -5759,12 +6196,14 @@ "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==" + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, "dependencies": { "mime-db": ">= 1.43.0 < 2" }, @@ -5776,6 +6215,7 @@ "version": "1.7.4", "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dev": true, "dependencies": { "accepts": "~1.3.5", "bytes": "3.0.0", @@ -5793,6 +6233,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "dependencies": { "ms": "2.0.0" } @@ -5800,27 +6241,32 @@ "node_modules/compression/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true }, "node_modules/compression/node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true }, "node_modules/confusing-browser-globals": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", - "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==" + "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", + "dev": true }, "node_modules/connect-history-api-fallback": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, "engines": { "node": ">=0.8" } @@ -5829,6 +6275,7 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -5840,6 +6287,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -5847,12 +6295,14 @@ "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "devOptional": true }, "node_modules/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -5860,12 +6310,14 @@ "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true }, "node_modules/core-js": { "version": "3.30.2", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.30.2.tgz", "integrity": "sha512-uBJiDmwqsbJCWHAwjrx3cvjbMXP7xD72Dmsn5LOJpiRmE3WbBbN5rCqQ2Qh6Ek6/eOrjlWngEynBWo4VxerQhg==", + "dev": true, "hasInstallScript": true, "funding": { "type": "opencollective", @@ -5876,6 +6328,7 @@ "version": "3.30.2", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.30.2.tgz", "integrity": "sha512-nriW1nuJjUgvkEjIot1Spwakz52V9YkYHZAQG6A1eCgC8AA1p0zngrQEP9R0+V6hji5XilWKG1Bd0YRppmGimA==", + "dev": true, "dependencies": { "browserslist": "^4.21.5" }, @@ -5888,6 +6341,7 @@ "version": "3.30.2", "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.30.2.tgz", "integrity": "sha512-p/npFUJXXBkCCTIlEGBdghofn00jWG6ZOtdoIXSJmAu2QBvN0IqpZXWweOytcwE6cfx8ZvVUy1vw8zxhe4Y2vg==", + "dev": true, "hasInstallScript": true, "funding": { "type": "opencollective", @@ -5897,12 +6351,14 @@ "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "devOptional": true, "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -5926,6 +6382,7 @@ "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -5939,6 +6396,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, "engines": { "node": ">=8" } @@ -5947,6 +6405,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", "integrity": "sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.9" }, @@ -5964,6 +6423,7 @@ "version": "6.4.0", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.0.tgz", "integrity": "sha512-jDfsatwWMWN0MODAFuHszfjphEXfNw9JUAhmY4pLu3TyTU+ohUpsbVtbU+1MZn4a47D9kqh03i4eyOm+74+zew==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14" }, @@ -5975,6 +6435,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-3.0.4.tgz", "integrity": "sha512-Vse0xpR1K9MNlp2j5w1pgWIJtm1a8qS0JwS9goFYcImjlHEmywP9VUF05aGBXzGpDJF86QXk4L0ypBmwPhGArw==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.9" }, @@ -5992,6 +6453,7 @@ "version": "6.7.3", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.3.tgz", "integrity": "sha512-qhOH1KlBMnZP8FzRO6YCH9UHXQhVMcEGLyNdb7Hv2cpcmJbW0YrddO+tG1ab5nT41KpHIYGsbeHqxB9xPu1pKQ==", + "dev": true, "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.19", @@ -6017,6 +6479,7 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", "integrity": "sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==", + "dev": true, "dependencies": { "cssnano": "^5.0.6", "jest-worker": "^27.0.2", @@ -6054,6 +6517,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -6069,6 +6533,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -6079,12 +6544,14 @@ "node_modules/css-minimizer-webpack-plugin/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true }, "node_modules/css-minimizer-webpack-plugin/node_modules/schema-utils": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.1.tgz", "integrity": "sha512-lELhBAAly9NowEsX0yZBlw9ahZG+sK/1RJ21EpzdYHKEs13Vku3LJ+MIPhh4sMs0oCCeufZQEQbMekiA4vuVIQ==", + "dev": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -6103,6 +6570,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -6111,6 +6579,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-6.0.3.tgz", "integrity": "sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==", + "dev": true, "bin": { "css-prefers-color-scheme": "dist/cli.cjs" }, @@ -6125,6 +6594,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.0.1", @@ -6139,12 +6609,14 @@ "node_modules/css-select-base-adapter": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", - "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==" + "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==", + "dev": true }, "node_modules/css-tree": { "version": "1.0.0-alpha.37", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", "integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==", + "dev": true, "dependencies": { "mdn-data": "2.0.4", "source-map": "^0.6.1" @@ -6157,6 +6629,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -6165,6 +6638,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, "engines": { "node": ">= 6" }, @@ -6176,6 +6650,7 @@ "version": "7.6.0", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.6.0.tgz", "integrity": "sha512-Nna7rph8V0jC6+JBY4Vk4ndErUmfJfV6NJCaZdurL0omggabiy+QB2HCQtu5c/ACLZ0I7REv7A4QyPIoYzZx0w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -6191,6 +6666,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, "bin": { "cssesc": "bin/cssesc" }, @@ -6202,6 +6678,7 @@ "version": "5.1.15", "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.15.tgz", "integrity": "sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==", + "dev": true, "dependencies": { "cssnano-preset-default": "^5.2.14", "lilconfig": "^2.0.3", @@ -6222,6 +6699,7 @@ "version": "5.2.14", "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", + "dev": true, "dependencies": { "css-declaration-sorter": "^6.3.1", "cssnano-utils": "^3.1.0", @@ -6264,6 +6742,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -6275,6 +6754,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, "dependencies": { "css-tree": "^1.1.2" }, @@ -6286,6 +6766,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" @@ -6297,12 +6778,14 @@ "node_modules/csso/node_modules/mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true }, "node_modules/csso/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -6310,12 +6793,14 @@ "node_modules/cssom": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", - "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==" + "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", + "dev": true }, "node_modules/cssstyle": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, "dependencies": { "cssom": "~0.3.6" }, @@ -6326,7 +6811,8 @@ "node_modules/cssstyle/node_modules/cssom": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==" + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true }, "node_modules/csstype": { "version": "3.1.2", @@ -6336,12 +6822,14 @@ "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==" + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true }, "node_modules/data-urls": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", + "dev": true, "dependencies": { "abab": "^2.0.3", "whatwg-mimetype": "^2.3.0", @@ -6355,6 +6843,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "dev": true, "dependencies": { "punycode": "^2.1.1" }, @@ -6366,6 +6855,7 @@ "version": "8.7.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "dev": true, "dependencies": { "lodash": "^4.7.0", "tr46": "^2.1.0", @@ -6379,6 +6869,7 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -6394,17 +6885,20 @@ "node_modules/decimal.js": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", - "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==" + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "dev": true }, "node_modules/deep-equal": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.1.tgz", "integrity": "sha512-lKdkdV6EOGoVn65XaOsPdH4rMxTZOnmFyuIkMjM1i5HHCbfjC97dawgTAy0deYNfuqUqW+Q5VrVaQYtUpSd6yQ==", + "dev": true, "dependencies": { "array-buffer-byte-length": "^1.0.0", "call-bind": "^1.0.2", @@ -6432,12 +6926,14 @@ "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -6446,6 +6942,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, "dependencies": { "execa": "^5.0.0" }, @@ -6457,6 +6954,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, "engines": { "node": ">=8" } @@ -6465,6 +6963,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", + "dev": true, "dependencies": { "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" @@ -6480,6 +6979,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "engines": { "node": ">=0.4.0" } @@ -6488,6 +6988,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, "engines": { "node": ">= 0.8" } @@ -6496,6 +6997,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -6505,6 +7007,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, "engines": { "node": ">=8" } @@ -6512,12 +7015,14 @@ "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true }, "node_modules/detect-port-alt": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", + "dev": true, "dependencies": { "address": "^1.0.1", "debug": "^2.6.0" @@ -6534,6 +7039,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "dependencies": { "ms": "2.0.0" } @@ -6541,7 +7047,8 @@ "node_modules/detect-port-alt/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true }, "node_modules/dexie": { "version": "3.2.3", @@ -6564,12 +7071,14 @@ "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true }, "node_modules/diff-sequences": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "dev": true, "engines": { "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } @@ -6578,6 +7087,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, "dependencies": { "path-type": "^4.0.0" }, @@ -6588,17 +7098,20 @@ "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true }, "node_modules/dns-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", - "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==" + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==", + "dev": true }, "node_modules/dns-packet": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.0.tgz", "integrity": "sha512-rza3UH1LwdHh9qyPXp8lkwpjSNk/AMD3dPytUoRoqnypDUhY0xvbdmVhWOfxO68frEfV9BU8V12Ez7ZsHGZpCQ==", + "dev": true, "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" }, @@ -6610,6 +7123,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, "dependencies": { "esutils": "^2.0.2" }, @@ -6621,6 +7135,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "dev": true, "dependencies": { "utila": "~0.4" } @@ -6638,6 +7153,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", @@ -6651,6 +7167,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, "funding": [ { "type": "github", @@ -6662,6 +7179,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "dev": true, "dependencies": { "webidl-conversions": "^5.0.0" }, @@ -6673,6 +7191,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "dev": true, "engines": { "node": ">=8" } @@ -6681,6 +7200,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, "dependencies": { "domelementtype": "^2.2.0" }, @@ -6695,6 +7215,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", @@ -6708,6 +7229,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" @@ -6717,6 +7239,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "dev": true, "engines": { "node": ">=10" } @@ -6724,22 +7247,26 @@ "node_modules/dotenv-expand": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", - "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==" + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true }, "node_modules/ejs": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "dev": true, "dependencies": { "jake": "^10.8.5" }, @@ -6753,12 +7280,14 @@ "node_modules/electron-to-chromium": { "version": "1.4.399", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.399.tgz", - "integrity": "sha512-+V1aNvVgoWNWYIbMOiQ1n5fRIaY4SlQ/uRlrsCjLrUwr/3OvQgiX2f5vdav4oArVT9TnttJKcPCqjwPNyZqw/A==" + "integrity": "sha512-+V1aNvVgoWNWYIbMOiQ1n5fRIaY4SlQ/uRlrsCjLrUwr/3OvQgiX2f5vdav4oArVT9TnttJKcPCqjwPNyZqw/A==", + "dev": true }, "node_modules/emittery": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==", + "dev": true, "engines": { "node": ">=10" }, @@ -6769,12 +7298,14 @@ "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true }, "node_modules/emojis-list": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, "engines": { "node": ">= 4" } @@ -6783,6 +7314,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, "engines": { "node": ">= 0.8" } @@ -6791,6 +7323,7 @@ "version": "5.14.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.14.0.tgz", "integrity": "sha512-+DCows0XNwLDcUhbFJPdlQEVnT2zXlCv7hPxemTz86/O+B/hCQ+mb7ydkPKiflpVraqLPCAfu7lDy+hBXueojw==", + "dev": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -6803,6 +7336,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } @@ -6811,6 +7345,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "devOptional": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -6827,6 +7362,7 @@ "version": "1.21.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.21.2.tgz", "integrity": "sha512-y/B5POM2iBnIxCiernH1G7rC9qQoM77lLIMQLuob0zhp8C56Po81+2Nj0WFKnd0pNReDTnkYryc+zhOzpEIROg==", + "dev": true, "dependencies": { "array-buffer-byte-length": "^1.0.0", "available-typed-arrays": "^1.0.5", @@ -6873,12 +7409,14 @@ "node_modules/es-array-method-boxes-properly": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", - "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==" + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", + "dev": true }, "node_modules/es-get-iterator": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -6897,12 +7435,14 @@ "node_modules/es-module-lexer": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.2.1.tgz", - "integrity": "sha512-9978wrXM50Y4rTMmW5kXIC09ZdXQZqkE4mxhwkd8VbzsGkXGPgV4zWuqQJgCEzYngdo2dYDa0l8xhX4fkSwJSg==" + "integrity": "sha512-9978wrXM50Y4rTMmW5kXIC09ZdXQZqkE4mxhwkd8VbzsGkXGPgV4zWuqQJgCEzYngdo2dYDa0l8xhX4fkSwJSg==", + "dev": true }, "node_modules/es-set-tostringtag": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", + "dev": true, "dependencies": { "get-intrinsic": "^1.1.3", "has": "^1.0.3", @@ -6916,6 +7456,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", + "dev": true, "dependencies": { "has": "^1.0.3" } @@ -6924,6 +7465,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, "dependencies": { "is-callable": "^1.1.4", "is-date-object": "^1.0.1", @@ -6940,6 +7482,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, "engines": { "node": ">=6" } @@ -6947,12 +7490,14 @@ "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "devOptional": true, "engines": { "node": ">=10" }, @@ -6964,6 +7509,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "dev": true, "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", @@ -6985,6 +7531,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dev": true, "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" @@ -6997,6 +7544,7 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", @@ -7013,6 +7561,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "dev": true, "engines": { "node": ">= 0.8.0" } @@ -7021,6 +7570,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "optional": true, "engines": { "node": ">=0.10.0" @@ -7030,6 +7580,7 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dev": true, "dependencies": { "prelude-ls": "~1.1.2" }, @@ -7041,6 +7592,7 @@ "version": "8.40.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", + "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", @@ -7097,6 +7649,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", "integrity": "sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==", + "dev": true, "dependencies": { "@babel/core": "^7.16.0", "@babel/eslint-parser": "^7.16.3", @@ -7124,6 +7677,7 @@ "version": "0.3.7", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.7.tgz", "integrity": "sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==", + "dev": true, "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.11.0", @@ -7134,6 +7688,7 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, "dependencies": { "ms": "^2.1.1" } @@ -7142,6 +7697,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", + "dev": true, "dependencies": { "debug": "^3.2.7" }, @@ -7158,6 +7714,7 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, "dependencies": { "ms": "^2.1.1" } @@ -7166,6 +7723,7 @@ "version": "8.0.3", "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-8.0.3.tgz", "integrity": "sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ==", + "dev": true, "dependencies": { "lodash": "^4.17.21", "string-natural-compare": "^3.0.1" @@ -7183,6 +7741,7 @@ "version": "2.27.5", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz", "integrity": "sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==", + "dev": true, "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", @@ -7211,6 +7770,7 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, "dependencies": { "ms": "^2.1.1" } @@ -7219,6 +7779,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, "dependencies": { "esutils": "^2.0.2" }, @@ -7230,6 +7791,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -7238,6 +7800,7 @@ "version": "25.7.0", "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", + "dev": true, "dependencies": { "@typescript-eslint/experimental-utils": "^5.0.0" }, @@ -7261,6 +7824,7 @@ "version": "6.7.1", "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.7.1.tgz", "integrity": "sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==", + "dev": true, "dependencies": { "@babel/runtime": "^7.20.7", "aria-query": "^5.1.3", @@ -7290,6 +7854,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -7298,6 +7863,7 @@ "version": "7.32.2", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.32.2.tgz", "integrity": "sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==", + "dev": true, "dependencies": { "array-includes": "^3.1.6", "array.prototype.flatmap": "^1.3.1", @@ -7326,6 +7892,7 @@ "version": "4.6.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", + "dev": true, "engines": { "node": ">=10" }, @@ -7337,6 +7904,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, "dependencies": { "esutils": "^2.0.2" }, @@ -7348,6 +7916,7 @@ "version": "2.0.0-next.4", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", + "dev": true, "dependencies": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", @@ -7364,6 +7933,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -7372,6 +7942,7 @@ "version": "5.11.0", "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.11.0.tgz", "integrity": "sha512-ELY7Gefo+61OfXKlQeXNIDVVLPcvKTeiQOoMZG9TeuWa7Ln4dUNRv8JdRWBQI9Mbb427XGlVB1aa1QPZxBJM8Q==", + "dev": true, "dependencies": { "@typescript-eslint/utils": "^5.58.0" }, @@ -7387,6 +7958,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", + "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -7402,6 +7974,7 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", + "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -7413,6 +7986,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz", "integrity": "sha512-avrKcGncpPbPSUHX6B3stNGzkKFto3eL+DKM4+VyMrVnhPc3vRczVlCq3uhuFOdRvDHTVXuzwk1ZKUrqDQHQ9w==", + "dev": true, "dependencies": { "@types/eslint": "^7.29.0 || ^8.4.1", "jest-worker": "^28.0.2", @@ -7436,6 +8010,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -7451,6 +8026,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -7462,6 +8038,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -7470,6 +8047,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz", "integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==", + "dev": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -7482,12 +8060,14 @@ "node_modules/eslint-webpack-plugin/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true }, "node_modules/eslint-webpack-plugin/node_modules/schema-utils": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.1.tgz", "integrity": "sha512-lELhBAAly9NowEsX0yZBlw9ahZG+sK/1RJ21EpzdYHKEs13Vku3LJ+MIPhh4sMs0oCCeufZQEQbMekiA4vuVIQ==", + "dev": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -7506,6 +8086,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7520,6 +8101,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -7533,12 +8115,14 @@ "node_modules/eslint/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -7554,6 +8138,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -7564,12 +8149,14 @@ "node_modules/eslint/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/eslint/node_modules/globals": { "version": "13.20.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "dev": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -7584,6 +8171,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -7592,6 +8180,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -7603,6 +8192,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7614,6 +8204,7 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, "engines": { "node": ">=10" }, @@ -7625,6 +8216,7 @@ "version": "9.5.2", "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", + "dev": true, "dependencies": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", @@ -7641,6 +8233,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -7653,6 +8246,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -7664,6 +8258,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "dependencies": { "estraverse": "^5.2.0" }, @@ -7675,6 +8270,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "engines": { "node": ">=4.0" } @@ -7682,12 +8278,14 @@ "node_modules/estree-walker": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==" + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -7696,6 +8294,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -7703,12 +8302,14 @@ "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, "engines": { "node": ">=0.8.x" } @@ -7717,6 +8318,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", @@ -7739,6 +8341,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, "engines": { "node": ">= 0.8.0" } @@ -7747,6 +8350,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "dev": true, "dependencies": { "@jest/types": "^27.5.1", "jest-get-type": "^27.5.1", @@ -7761,6 +8365,7 @@ "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dev": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7801,12 +8406,14 @@ "node_modules/express/node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "dependencies": { "ms": "2.0.0" } @@ -7814,17 +8421,20 @@ "node_modules/express/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "node_modules/fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -7840,6 +8450,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -7850,17 +8461,20 @@ "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dev": true, "dependencies": { "reusify": "^1.0.4" } @@ -7869,6 +8483,7 @@ "version": "0.11.4", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, "dependencies": { "websocket-driver": ">=0.5.1" }, @@ -7880,6 +8495,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, "dependencies": { "bser": "2.1.1" } @@ -7888,6 +8504,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, "dependencies": { "flat-cache": "^3.0.4" }, @@ -7899,6 +8516,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dev": true, "dependencies": { "loader-utils": "^2.0.0", "schema-utils": "^3.0.0" @@ -7918,6 +8536,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, "dependencies": { "minimatch": "^5.0.1" } @@ -7926,6 +8545,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -7934,6 +8554,7 @@ "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -7945,6 +8566,7 @@ "version": "8.0.7", "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", + "dev": true, "engines": { "node": ">= 0.4.0" } @@ -7953,6 +8575,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -7964,6 +8587,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -7981,6 +8605,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "dependencies": { "ms": "2.0.0" } @@ -7988,12 +8613,14 @@ "node_modules/finalhandler/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, "dependencies": { "commondir": "^1.0.1", "make-dir": "^3.0.2", @@ -8017,6 +8644,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -8032,6 +8660,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, "dependencies": { "flatted": "^3.1.0", "rimraf": "^3.0.2" @@ -8043,12 +8672,14 @@ "node_modules/flatted": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" + "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "dev": true }, "node_modules/follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "dev": true, "funding": [ { "type": "individual", @@ -8068,6 +8699,7 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, "dependencies": { "is-callable": "^1.1.3" } @@ -8076,6 +8708,7 @@ "version": "6.5.3", "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz", "integrity": "sha512-SbH/l9ikmMWycd5puHJKTkZJKddF4iRLyW3DeZ08HTI7NGyLS38MXd/KGgeWumQO7YNQbW2u/NtPT2YowbPaGQ==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.8.3", "@types/json-schema": "^7.0.5", @@ -8114,6 +8747,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -8128,6 +8762,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -8143,6 +8778,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -8153,12 +8789,14 @@ "node_modules/fork-ts-checker-webpack-plugin/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dev": true, "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.1.0", @@ -8174,6 +8812,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -8188,6 +8827,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -8196,6 +8836,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dev": true, "dependencies": { "@types/json-schema": "^7.0.4", "ajv": "^6.12.2", @@ -8213,6 +8854,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -8224,6 +8866,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true, "engines": { "node": ">=6" } @@ -8232,6 +8875,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -8245,6 +8889,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -8253,6 +8898,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", + "dev": true, "engines": { "node": "*" }, @@ -8265,6 +8911,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -8273,6 +8920,7 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -8285,17 +8933,20 @@ "node_modules/fs-monkey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", - "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==" + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "dev": true }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -8308,12 +8959,14 @@ "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "devOptional": true }, "node_modules/function.prototype.name": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.3", @@ -8331,6 +8984,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8339,6 +8993,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "engines": { "node": ">=6.9.0" } @@ -8347,6 +9002,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -8355,6 +9011,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "dev": true, "dependencies": { "function-bind": "^1.1.1", "has": "^1.0.3", @@ -8368,12 +9025,14 @@ "node_modules/get-own-enumerable-property-symbols": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", - "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==" + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, "engines": { "node": ">=8.0.0" } @@ -8382,6 +9041,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, "engines": { "node": ">=10" }, @@ -8393,6 +9053,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.1" @@ -8408,6 +9069,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -8427,6 +9089,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -8437,12 +9100,14 @@ "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true }, "node_modules/global-modules": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, "dependencies": { "global-prefix": "^3.0.0" }, @@ -8454,6 +9119,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, "dependencies": { "ini": "^1.3.5", "kind-of": "^6.0.2", @@ -8467,6 +9133,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -8478,6 +9145,7 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, "engines": { "node": ">=4" } @@ -8486,6 +9154,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, "dependencies": { "define-properties": "^1.1.3" }, @@ -8500,6 +9169,7 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", @@ -8519,6 +9189,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, "dependencies": { "get-intrinsic": "^1.1.3" }, @@ -8529,17 +9200,20 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true }, "node_modules/grapheme-splitter": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==" + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true }, "node_modules/gzip-size": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, "dependencies": { "duplexer": "^0.1.2" }, @@ -8553,17 +9227,20 @@ "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==" + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true }, "node_modules/harmony-reflect": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", - "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==" + "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==", + "dev": true }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "devOptional": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -8575,6 +9252,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8583,6 +9261,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "devOptional": true, "engines": { "node": ">=4" } @@ -8591,6 +9270,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "dev": true, "dependencies": { "get-intrinsic": "^1.1.1" }, @@ -8602,6 +9282,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -8613,6 +9294,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -8624,6 +9306,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "dev": true, "dependencies": { "has-symbols": "^1.0.2" }, @@ -8638,6 +9321,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, "bin": { "he": "bin/he" } @@ -8663,6 +9347,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==", + "dev": true, "engines": { "node": ">= 6.0.0" } @@ -8671,6 +9356,7 @@ "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, "dependencies": { "inherits": "^2.0.1", "obuf": "^1.0.0", @@ -8681,12 +9367,14 @@ "node_modules/hpack.js/node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true }, "node_modules/hpack.js/node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -8700,12 +9388,14 @@ "node_modules/hpack.js/node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true }, "node_modules/hpack.js/node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -8714,6 +9404,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "dev": true, "dependencies": { "whatwg-encoding": "^1.0.5" }, @@ -8724,17 +9415,20 @@ "node_modules/html-entities": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", - "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==" + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true }, "node_modules/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "dev": true, "dependencies": { "camel-case": "^4.1.2", "clean-css": "^5.2.2", @@ -8763,6 +9457,7 @@ "version": "5.5.1", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.1.tgz", "integrity": "sha512-cTUzZ1+NqjGEKjmVgZKLMdiFg3m9MdRXkZW2OEe69WYVi5ONLMmlnSZdXzGGMOq0C8jGDrL6EWyEDDUioHO/pA==", + "dev": true, "dependencies": { "@types/html-minifier-terser": "^6.0.0", "html-minifier-terser": "^6.0.2", @@ -8785,6 +9480,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -8802,12 +9498,14 @@ "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==" + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", @@ -8822,12 +9520,14 @@ "node_modules/http-parser-js": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", - "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==" + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "dev": true }, "node_modules/http-proxy": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", @@ -8841,6 +9541,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, "dependencies": { "@tootallnate/once": "1", "agent-base": "6", @@ -8854,6 +9555,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dev": true, "dependencies": { "@types/http-proxy": "^1.17.8", "http-proxy": "^1.18.1", @@ -8877,6 +9579,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -8889,6 +9592,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, "engines": { "node": ">=10.17.0" } @@ -8940,6 +9644,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -8951,6 +9656,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, "engines": { "node": "^10 || ^12 || >= 14" }, @@ -8961,12 +9667,14 @@ "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", - "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true }, "node_modules/identity-obj-proxy": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==", + "dev": true, "dependencies": { "harmony-reflect": "^1.4.6" }, @@ -8978,6 +9686,7 @@ "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true, "engines": { "node": ">= 4" } @@ -8986,6 +9695,7 @@ "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "dev": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -8995,6 +9705,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "devOptional": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -9010,6 +9721,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" @@ -9028,6 +9740,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "engines": { "node": ">=0.8.19" } @@ -9036,6 +9749,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -9044,17 +9758,20 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true }, "node_modules/internal-slot": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", + "dev": true, "dependencies": { "get-intrinsic": "^1.2.0", "has": "^1.0.3", @@ -9068,6 +9785,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", "integrity": "sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==", + "dev": true, "engines": { "node": ">= 10" } @@ -9076,6 +9794,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -9091,6 +9810,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.2.0", @@ -9103,12 +9823,14 @@ "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "devOptional": true }, "node_modules/is-bigint": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, "dependencies": { "has-bigints": "^1.0.1" }, @@ -9120,6 +9842,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -9131,6 +9854,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -9146,6 +9870,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -9157,6 +9882,7 @@ "version": "2.12.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz", "integrity": "sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==", + "devOptional": true, "dependencies": { "has": "^1.0.3" }, @@ -9168,6 +9894,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -9182,6 +9909,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, "bin": { "is-docker": "cli.js" }, @@ -9196,6 +9924,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -9204,6 +9933,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "engines": { "node": ">=8" } @@ -9212,6 +9942,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, "engines": { "node": ">=6" } @@ -9220,6 +9951,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -9231,6 +9963,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9238,12 +9971,14 @@ "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==" + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true }, "node_modules/is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -9255,6 +9990,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "engines": { "node": ">=0.12.0" } @@ -9263,6 +9999,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -9277,6 +10014,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -9285,6 +10023,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -9293,6 +10032,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, "engines": { "node": ">=10" }, @@ -9303,12 +10043,14 @@ "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -9324,6 +10066,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -9332,6 +10075,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", + "dev": true, "engines": { "node": ">=6" } @@ -9340,6 +10084,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9348,6 +10093,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2" }, @@ -9359,6 +10105,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, "engines": { "node": ">=8" }, @@ -9370,6 +10117,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -9384,6 +10132,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, "dependencies": { "has-symbols": "^1.0.2" }, @@ -9398,6 +10147,7 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", + "dev": true, "dependencies": { "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", @@ -9415,12 +10165,14 @@ "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true }, "node_modules/is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9429,6 +10181,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, "dependencies": { "call-bind": "^1.0.2" }, @@ -9440,6 +10193,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.1" @@ -9452,6 +10206,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, "dependencies": { "is-docker": "^2.0.0" }, @@ -9462,17 +10217,20 @@ "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true }, "node_modules/istanbul-lib-coverage": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true, "engines": { "node": ">=8" } @@ -9481,6 +10239,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", @@ -9496,6 +10255,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -9504,6 +10264,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^3.0.0", @@ -9517,6 +10278,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -9525,6 +10287,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9536,6 +10299,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, "dependencies": { "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0", @@ -9549,6 +10313,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -9557,6 +10322,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", + "dev": true, "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -9569,6 +10335,7 @@ "version": "10.8.6", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.6.tgz", "integrity": "sha512-G43Ub9IYEFfu72sua6rzooi8V8Gz2lkfk48rW20vEWCGizeaEPlKB1Kh8JIA84yQbiAEfqlPmSpGgCKKxH3rDA==", + "dev": true, "dependencies": { "async": "^3.2.3", "chalk": "^4.0.2", @@ -9586,6 +10353,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -9600,6 +10368,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -9615,6 +10384,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -9625,12 +10395,14 @@ "node_modules/jake/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jake/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -9639,6 +10411,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9650,6 +10423,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "dev": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -9674,6 +10448,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", + "dev": true, "dependencies": { "@jest/types": "^27.5.1", "execa": "^5.0.0", @@ -9687,6 +10462,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", + "dev": true, "dependencies": { "@jest/environment": "^27.5.1", "@jest/test-result": "^27.5.1", @@ -9716,6 +10492,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -9730,6 +10507,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -9745,6 +10523,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -9755,12 +10534,14 @@ "node_modules/jest-circus/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-circus/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -9769,6 +10550,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9780,6 +10562,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", + "dev": true, "dependencies": { "@jest/core": "^27.5.1", "@jest/test-result": "^27.5.1", @@ -9813,6 +10596,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -9827,6 +10611,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -9842,6 +10627,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -9852,12 +10638,14 @@ "node_modules/jest-cli/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-cli/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -9866,6 +10654,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9877,6 +10666,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", + "dev": true, "dependencies": { "@babel/core": "^7.8.0", "@jest/test-sequencer": "^27.5.1", @@ -9919,6 +10709,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -9933,6 +10724,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -9948,6 +10740,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -9958,12 +10751,14 @@ "node_modules/jest-config/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-config/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -9972,6 +10767,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9983,6 +10779,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, "dependencies": { "chalk": "^4.0.0", "diff-sequences": "^27.5.1", @@ -9997,6 +10794,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10011,6 +10809,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10026,6 +10825,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -10036,12 +10836,14 @@ "node_modules/jest-diff/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-diff/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -10050,6 +10852,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10061,6 +10864,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", + "dev": true, "dependencies": { "detect-newline": "^3.0.0" }, @@ -10072,6 +10876,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", + "dev": true, "dependencies": { "@jest/types": "^27.5.1", "chalk": "^4.0.0", @@ -10087,6 +10892,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10101,6 +10907,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10116,6 +10923,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -10126,12 +10934,14 @@ "node_modules/jest-each/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-each/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -10140,6 +10950,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10151,6 +10962,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", + "dev": true, "dependencies": { "@jest/environment": "^27.5.1", "@jest/fake-timers": "^27.5.1", @@ -10168,6 +10980,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", + "dev": true, "dependencies": { "@jest/environment": "^27.5.1", "@jest/fake-timers": "^27.5.1", @@ -10184,6 +10997,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, "engines": { "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } @@ -10192,6 +11006,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "dev": true, "dependencies": { "@jest/types": "^27.5.1", "@types/graceful-fs": "^4.1.2", @@ -10217,6 +11032,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", + "dev": true, "dependencies": { "@jest/environment": "^27.5.1", "@jest/source-map": "^27.5.1", @@ -10244,6 +11060,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10258,6 +11075,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10273,6 +11091,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -10283,12 +11102,14 @@ "node_modules/jest-jasmine2/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-jasmine2/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -10297,6 +11118,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10308,6 +11130,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", + "dev": true, "dependencies": { "jest-get-type": "^27.5.1", "pretty-format": "^27.5.1" @@ -10320,6 +11143,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "dev": true, "dependencies": { "chalk": "^4.0.0", "jest-diff": "^27.5.1", @@ -10334,6 +11158,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10348,6 +11173,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10363,6 +11189,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -10373,12 +11200,14 @@ "node_modules/jest-matcher-utils/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-matcher-utils/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -10387,6 +11216,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10398,6 +11228,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^27.5.1", @@ -10417,6 +11248,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10431,6 +11263,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10446,6 +11279,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -10456,12 +11290,14 @@ "node_modules/jest-message-util/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-message-util/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -10470,6 +11306,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10481,6 +11318,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "dev": true, "dependencies": { "@jest/types": "^27.5.1", "@types/node": "*" @@ -10493,6 +11331,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, "engines": { "node": ">=6" }, @@ -10509,6 +11348,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "dev": true, "engines": { "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } @@ -10517,6 +11357,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", + "dev": true, "dependencies": { "@jest/types": "^27.5.1", "chalk": "^4.0.0", @@ -10537,6 +11378,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", + "dev": true, "dependencies": { "@jest/types": "^27.5.1", "jest-regex-util": "^27.5.1", @@ -10550,6 +11392,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10564,6 +11407,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10579,6 +11423,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -10589,12 +11434,14 @@ "node_modules/jest-resolve/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-resolve/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -10603,6 +11450,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10614,6 +11462,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", + "dev": true, "dependencies": { "@jest/console": "^27.5.1", "@jest/environment": "^27.5.1", @@ -10645,6 +11494,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10659,6 +11509,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10674,6 +11525,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -10684,12 +11536,14 @@ "node_modules/jest-runner/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-runner/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -10698,6 +11552,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10709,6 +11564,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", + "dev": true, "dependencies": { "@jest/environment": "^27.5.1", "@jest/fake-timers": "^27.5.1", @@ -10741,6 +11597,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10755,6 +11612,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10770,6 +11628,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -10780,12 +11639,14 @@ "node_modules/jest-runtime/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-runtime/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -10794,6 +11655,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10805,6 +11667,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", + "dev": true, "dependencies": { "@types/node": "*", "graceful-fs": "^4.2.9" @@ -10817,6 +11680,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", + "dev": true, "dependencies": { "@babel/core": "^7.7.2", "@babel/generator": "^7.7.2", @@ -10849,6 +11713,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10863,6 +11728,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10878,6 +11744,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -10888,12 +11755,14 @@ "node_modules/jest-snapshot/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-snapshot/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -10902,6 +11771,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10913,6 +11783,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, "dependencies": { "@jest/types": "^27.5.1", "@types/node": "*", @@ -10929,6 +11800,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10943,6 +11815,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10958,6 +11831,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -10968,12 +11842,14 @@ "node_modules/jest-util/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-util/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -10982,6 +11858,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10993,6 +11870,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", + "dev": true, "dependencies": { "@jest/types": "^27.5.1", "camelcase": "^6.2.0", @@ -11009,6 +11887,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -11023,6 +11902,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -11038,6 +11918,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -11048,12 +11929,14 @@ "node_modules/jest-validate/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-validate/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -11062,6 +11945,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -11073,6 +11957,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/jest-watch-typeahead/-/jest-watch-typeahead-1.1.0.tgz", "integrity": "sha512-Va5nLSJTN7YFtC2jd+7wsoe1pNe5K4ShLux/E5iHEwlB9AxaxmggY7to9KUqKojhaJw3aXqt5WAb4jGPOolpEw==", + "dev": true, "dependencies": { "ansi-escapes": "^4.3.1", "chalk": "^4.0.0", @@ -11093,6 +11978,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/@jest/console/-/console-28.1.3.tgz", "integrity": "sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw==", + "dev": true, "dependencies": { "@jest/types": "^28.1.3", "@types/node": "*", @@ -11109,6 +11995,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, "engines": { "node": ">=8" } @@ -11117,6 +12004,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-28.1.3.tgz", "integrity": "sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg==", + "dev": true, "dependencies": { "@jest/console": "^28.1.3", "@jest/types": "^28.1.3", @@ -11131,6 +12019,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-28.1.3.tgz", "integrity": "sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==", + "dev": true, "dependencies": { "@jest/schemas": "^28.1.3", "@types/istanbul-lib-coverage": "^2.0.0", @@ -11147,6 +12036,7 @@ "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", + "dev": true, "dependencies": { "@types/yargs-parser": "*" } @@ -11155,6 +12045,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -11169,6 +12060,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -11184,6 +12076,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -11194,12 +12087,14 @@ "node_modules/jest-watch-typeahead/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-watch-typeahead/node_modules/emittery": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", "integrity": "sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==", + "dev": true, "engines": { "node": ">=12" }, @@ -11211,6 +12106,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -11219,6 +12115,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-28.1.3.tgz", "integrity": "sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.12.13", "@jest/types": "^28.1.3", @@ -11238,6 +12135,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, "engines": { "node": ">=8" } @@ -11246,6 +12144,7 @@ "version": "28.0.2", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-28.0.2.tgz", "integrity": "sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw==", + "dev": true, "engines": { "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" } @@ -11254,6 +12153,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-28.1.3.tgz", "integrity": "sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==", + "dev": true, "dependencies": { "@jest/types": "^28.1.3", "@types/node": "*", @@ -11270,6 +12170,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-28.1.3.tgz", "integrity": "sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==", + "dev": true, "dependencies": { "@jest/test-result": "^28.1.3", "@jest/types": "^28.1.3", @@ -11288,6 +12189,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" @@ -11300,6 +12202,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -11311,6 +12214,7 @@ "version": "28.1.3", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "dev": true, "dependencies": { "@jest/schemas": "^28.1.3", "ansi-regex": "^5.0.1", @@ -11325,6 +12229,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "engines": { "node": ">=10" }, @@ -11336,6 +12241,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, "engines": { "node": ">=12" }, @@ -11347,6 +12253,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", + "dev": true, "dependencies": { "char-regex": "^2.0.0", "strip-ansi": "^7.0.1" @@ -11362,6 +12269,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz", "integrity": "sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==", + "dev": true, "engines": { "node": ">=12.20" } @@ -11370,6 +12278,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -11384,6 +12293,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, "engines": { "node": ">=12" }, @@ -11395,6 +12305,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -11406,6 +12317,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", + "dev": true, "dependencies": { "@jest/test-result": "^27.5.1", "@jest/types": "^27.5.1", @@ -11423,6 +12335,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -11437,6 +12350,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -11452,6 +12366,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -11462,12 +12377,14 @@ "node_modules/jest-watcher/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/jest-watcher/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -11476,6 +12393,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -11487,6 +12405,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -11500,6 +12419,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -11508,6 +12428,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -11522,6 +12443,7 @@ "version": "1.18.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.18.2.tgz", "integrity": "sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==", + "dev": true, "bin": { "jiti": "bin/jiti.js" } @@ -11535,6 +12457,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz", "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==", + "dev": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/js-sdsl" @@ -11549,6 +12472,7 @@ "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -11561,6 +12485,7 @@ "version": "16.7.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", + "dev": true, "dependencies": { "abab": "^2.0.5", "acorn": "^8.2.4", @@ -11606,6 +12531,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "dev": true, "dependencies": { "punycode": "^2.1.1" }, @@ -11617,6 +12543,7 @@ "version": "8.7.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "dev": true, "dependencies": { "lodash": "^4.7.0", "tr46": "^2.1.0", @@ -11630,6 +12557,7 @@ "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, "bin": { "jsesc": "bin/jsesc" }, @@ -11640,27 +12568,32 @@ "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "devOptional": true }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -11672,6 +12605,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, "dependencies": { "universalify": "^2.0.0" }, @@ -11683,6 +12617,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -11691,6 +12626,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", + "dev": true, "dependencies": { "array-includes": "^3.1.5", "object.assign": "^4.1.3" @@ -11703,6 +12639,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -11711,6 +12648,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, "engines": { "node": ">=6" } @@ -11719,6 +12657,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "dev": true, "engines": { "node": ">= 8" } @@ -11726,12 +12665,14 @@ "node_modules/language-subtag-registry": { "version": "0.3.22", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", - "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==" + "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", + "dev": true }, "node_modules/language-tags": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", + "dev": true, "dependencies": { "language-subtag-registry": "~0.3.2" } @@ -11740,6 +12681,7 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.0.tgz", "integrity": "sha512-JpDCcQnyAAzZZaZ7vEiSqL690w7dAEyLao+KC96zBplnYbJS7TYNjvM3M7y3dGz+v7aIsJk3hllWuc0kWAjyRQ==", + "dev": true, "dependencies": { "picocolors": "^1.0.0", "shell-quote": "^1.7.3" @@ -11749,6 +12691,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, "engines": { "node": ">=6" } @@ -11757,6 +12700,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -11769,6 +12713,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, "engines": { "node": ">=10" } @@ -11776,12 +12721,14 @@ "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "devOptional": true }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, "engines": { "node": ">=6.11.5" } @@ -11790,6 +12737,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, "dependencies": { "big.js": "^5.2.2", "emojis-list": "^3.0.0", @@ -11803,6 +12751,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -11816,32 +12765,38 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==" + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true }, "node_modules/loose-envify": { "version": "1.4.0", @@ -11858,6 +12813,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, "dependencies": { "tslib": "^2.0.3" } @@ -11866,6 +12822,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "dependencies": { "yallist": "^3.0.2" } @@ -11874,6 +12831,7 @@ "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, "dependencies": { "sourcemap-codec": "^1.4.8" } @@ -11882,6 +12840,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, "dependencies": { "semver": "^6.0.0" }, @@ -11896,6 +12855,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } @@ -11904,6 +12864,7 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, "dependencies": { "tmpl": "1.0.5" } @@ -11911,12 +12872,14 @@ "node_modules/mdn-data": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", - "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==" + "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==", + "dev": true }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -11925,6 +12888,7 @@ "version": "3.5.1", "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.1.tgz", "integrity": "sha512-UWbFJKvj5k+nETdteFndTpYxdeTMox/ULeqX5k/dpaQJCCFmj5EeKv3dBcyO2xmkRAx2vppRu5dVG7SOtsGOzA==", + "dev": true, "dependencies": { "fs-monkey": "^1.0.3" }, @@ -11935,17 +12899,20 @@ "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dev": true }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "engines": { "node": ">= 8" } @@ -11954,6 +12921,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -11962,6 +12930,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -11974,6 +12943,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, "bin": { "mime": "cli.js" }, @@ -11985,6 +12955,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -11993,6 +12964,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -12004,6 +12976,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, "engines": { "node": ">=6" } @@ -12012,6 +12985,7 @@ "version": "2.7.5", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.5.tgz", "integrity": "sha512-9HaR++0mlgom81s95vvNjxkg52n2b5s//3ZTI1EtzFb98awsLSivs2LMsVqnQ3ay0PVhqWcGNyDaTE961FOcjQ==", + "dev": true, "dependencies": { "schema-utils": "^4.0.0" }, @@ -12030,6 +13004,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -12045,6 +13020,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -12055,12 +13031,14 @@ "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true }, "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.1.tgz", "integrity": "sha512-lELhBAAly9NowEsX0yZBlw9ahZG+sK/1RJ21EpzdYHKEs13Vku3LJ+MIPhh4sMs0oCCeufZQEQbMekiA4vuVIQ==", + "dev": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -12078,12 +13056,14 @@ "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -12095,6 +13075,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -12103,6 +13084,7 @@ "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, "dependencies": { "minimist": "^1.2.6" }, @@ -12113,12 +13095,14 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true }, "node_modules/multicast-dns": { "version": "7.2.5", "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" @@ -12131,6 +13115,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", @@ -12141,6 +13126,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "dev": true, "funding": [ { "type": "github", @@ -12157,17 +13143,20 @@ "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true }, "node_modules/natural-compare-lite": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==" + "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", + "dev": true }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -12175,12 +13164,14 @@ "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" @@ -12209,6 +13200,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, "engines": { "node": ">= 6.13.0" } @@ -12216,17 +13208,20 @@ "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==" + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true }, "node_modules/node-releases": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", - "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==" + "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==", + "dev": true }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -12235,6 +13230,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -12243,6 +13239,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, "engines": { "node": ">=10" }, @@ -12254,6 +13251,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, "dependencies": { "path-key": "^3.0.0" }, @@ -12265,6 +13263,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, "dependencies": { "boolbase": "^1.0.0" }, @@ -12275,7 +13274,8 @@ "node_modules/nwsapi": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.4.tgz", - "integrity": "sha512-NHj4rzRo0tQdijE9ZqAx6kYDcoRwYwSYzCA8MY3JzfxlrvEU0jhnhJT9BhqhJs7I/dKcrDm6TyulaRqZPIhN5g==" + "integrity": "sha512-NHj4rzRo0tQdijE9ZqAx6kYDcoRwYwSYzCA8MY3JzfxlrvEU0jhnhJT9BhqhJs7I/dKcrDm6TyulaRqZPIhN5g==", + "dev": true }, "node_modules/object-assign": { "version": "4.1.1", @@ -12289,6 +13289,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "engines": { "node": ">= 6" } @@ -12297,6 +13298,7 @@ "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -12305,6 +13307,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.5.tgz", "integrity": "sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.3" @@ -12320,6 +13323,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, "engines": { "node": ">= 0.4" } @@ -12328,6 +13332,7 @@ "version": "4.1.4", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -12345,6 +13350,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.6.tgz", "integrity": "sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -12358,6 +13364,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.6.tgz", "integrity": "sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -12374,6 +13381,7 @@ "version": "2.1.6", "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.6.tgz", "integrity": "sha512-lq+61g26E/BgHv0ZTFgRvi7NMEPuAxLkFU7rukXjc/AlwH4Am5xXVnIXy3un1bg/JPbXHrixRkK1itUzzPiIjQ==", + "dev": true, "dependencies": { "array.prototype.reduce": "^1.0.5", "call-bind": "^1.0.2", @@ -12392,6 +13400,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.2.tgz", "integrity": "sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==", + "dev": true, "dependencies": { "define-properties": "^1.1.4", "es-abstract": "^1.20.4" @@ -12404,6 +13413,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.6.tgz", "integrity": "sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -12419,12 +13429,14 @@ "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, "dependencies": { "ee-first": "1.1.1" }, @@ -12436,6 +13448,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true, "engines": { "node": ">= 0.8" } @@ -12444,6 +13457,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, "dependencies": { "wrappy": "1" } @@ -12452,6 +13466,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, "dependencies": { "mimic-fn": "^2.1.0" }, @@ -12466,6 +13481,7 @@ "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", @@ -12482,6 +13498,7 @@ "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -12498,6 +13515,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -12512,6 +13530,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -12526,6 +13545,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" @@ -12538,6 +13558,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, "engines": { "node": ">=6" } @@ -12546,6 +13567,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" @@ -12555,6 +13577,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "devOptional": true, "dependencies": { "callsites": "^3.0.0" }, @@ -12566,6 +13589,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "devOptional": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -12582,12 +13606,14 @@ "node_modules/parse5": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, "engines": { "node": ">= 0.8" } @@ -12596,6 +13622,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" @@ -12605,6 +13632,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "engines": { "node": ">=8" } @@ -12613,6 +13641,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -12621,6 +13650,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "engines": { "node": ">=8" } @@ -12628,17 +13658,20 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "devOptional": true }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "devOptional": true, "engines": { "node": ">=8" } @@ -12646,17 +13679,20 @@ "node_modules/performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -12668,6 +13704,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -12676,6 +13713,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", + "dev": true, "engines": { "node": ">= 6" } @@ -12684,6 +13722,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, "dependencies": { "find-up": "^4.0.0" }, @@ -12695,6 +13734,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -12707,6 +13747,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -12718,6 +13759,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "dependencies": { "p-try": "^2.0.0" }, @@ -12732,6 +13774,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -12743,6 +13786,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, "dependencies": { "find-up": "^3.0.0" }, @@ -12754,6 +13798,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, "dependencies": { "locate-path": "^3.0.0" }, @@ -12765,6 +13810,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" @@ -12777,6 +13823,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "dependencies": { "p-try": "^2.0.0" }, @@ -12791,6 +13838,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, "dependencies": { "p-limit": "^2.0.0" }, @@ -12802,6 +13850,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, "engines": { "node": ">=4" } @@ -12810,6 +13859,7 @@ "version": "8.4.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -12837,6 +13887,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz", "integrity": "sha512-XIidXV8fDr0kKt28vqki84fRK8VW8eTuIa4PChv2MqKuT6C9UjmSKzen6KaWhWEoYvwxFCa7n/tC1SZ3tyq4SQ==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.10" }, @@ -12855,6 +13906,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-browser-comments/-/postcss-browser-comments-4.0.0.tgz", "integrity": "sha512-X9X9/WN3KIvY9+hNERUqX9gncsgBA25XaeR+jshHz2j8+sYyHktHw1JdKuMjeLpGktXidqDhA7b/qm1mrBDmgg==", + "dev": true, "engines": { "node": ">=8" }, @@ -12867,6 +13919,7 @@ "version": "8.2.4", "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.9", "postcss-value-parser": "^4.2.0" @@ -12879,6 +13932,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -12893,6 +13947,7 @@ "version": "4.2.4", "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-4.2.4.tgz", "integrity": "sha512-2yrTAUZUab9s6CpxkxC4rVgFEVaR6/2Pipvi6qcgvnYiVqZcbDHEoBDhrXzyb7Efh2CCfHQNtcqWcIruDTIUeg==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -12911,6 +13966,7 @@ "version": "8.0.4", "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-8.0.4.tgz", "integrity": "sha512-nLo2DCRC9eE4w2JmuKgVA3fGL3d01kGq752pVALF68qpGLmx2Qrk91QTKkdUqqp45T1K1XV8IhQpcu1hoAQflQ==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -12929,6 +13985,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-7.1.1.tgz", "integrity": "sha512-pGxkuVEInwLHgkNxUc4sdg4g3py7zUeCQ9sMfwyHAT+Ezk8a4OaaVZ8lIY5+oNqA/BXXgLyXv0+5wHP68R79hg==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -12947,6 +14004,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", @@ -12964,6 +14022,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" @@ -12979,6 +14038,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-8.0.2.tgz", "integrity": "sha512-7yi25vDAoHAkbhAzX9dHx2yc6ntS4jQvejrNcC+csQJAXjj15e7VcWfMgLqBNAbOvqi5uIa9huOVwdHbf+sKqg==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -12997,6 +14057,7 @@ "version": "12.1.11", "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-12.1.11.tgz", "integrity": "sha512-0IDJYhgU8xDv1KY6+VgUwuQkVtmYzRwu+dMjnmdMafXYv86SWqfxkc7qdDvWS38vsjaEtv8e0vGOUQrAiMBLpQ==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13015,6 +14076,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-6.0.3.tgz", "integrity": "sha512-fgVkmyiWDwmD3JbpCmB45SvvlCD6z9CG6Ie6Iere22W5aHea6oWa7EM2bpnv2Fj3I94L3VbtvX9KqwSi5aFzSg==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.4" }, @@ -13033,6 +14095,7 @@ "version": "6.0.5", "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-6.0.5.tgz", "integrity": "sha512-eqn4m70P031PF7ZQIvSgy9RSJ5uI2171O/OO/zcRNYpJbvaeKFUlar1aJ7rmgiQtbm0FSPsRewjpdS0Oew7MPA==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.10" }, @@ -13051,6 +14114,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -13062,6 +14126,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -13073,6 +14138,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -13084,6 +14150,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -13095,6 +14162,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-3.1.2.tgz", "integrity": "sha512-GX+FuE/uBR6eskOK+4vkXgT6pDkexLokPaz/AbJna9s5Kzp/yl488pKPjhy0obB475ovfT1Wv8ho7U/cHNaRgQ==", + "dev": true, "dependencies": { "@csstools/postcss-progressive-custom-properties": "^1.1.0", "postcss-value-parser": "^4.2.0" @@ -13114,6 +14182,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/postcss-env-function/-/postcss-env-function-4.0.6.tgz", "integrity": "sha512-kpA6FsLra+NqcFnL81TnsU+Z7orGtDTxcOhl6pwXeEq1yFPpRMkCDpHhrz8CFQDr/Wfm0jLiNQ1OsGGPjlqPwA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13128,6 +14197,7 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz", "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==", + "dev": true, "peerDependencies": { "postcss": "^8.1.4" } @@ -13136,6 +14206,7 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-6.0.4.tgz", "integrity": "sha512-QcKuUU/dgNsstIK6HELFRT5Y3lbrMLEOwG+A4s5cA+fx3A3y/JTq3X9LaOj3OC3ALH0XqyrgQIgey/MIZ8Wczw==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.9" }, @@ -13150,6 +14221,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-5.0.4.tgz", "integrity": "sha512-vvjDN++C0mu8jz4af5d52CB184ogg/sSxAFS+oUJQq2SuCe7T5U2iIsVJtsCp2d6R4j0jr5+q3rPkBVZkXD9fQ==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.9" }, @@ -13164,6 +14236,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", + "dev": true, "peerDependencies": { "postcss": "^8.1.0" } @@ -13172,6 +14245,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-3.0.5.tgz", "integrity": "sha512-IuE6gKSdoUNcvkGIqdtjtcMtZIFyXZhmFd5RUlg97iVEvp1BZKV5ngsAjCjrVy+14uhGBQl9tzmi1Qwq4kqVOg==", + "dev": true, "engines": { "node": "^12 || ^14 || >=16" }, @@ -13187,6 +14261,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-4.0.7.tgz", "integrity": "sha512-9T2r9rsvYzm5ndsBE8WgtrMlIT7VbtTfE7b3BQnudUqnBcBo7L758oc+o+pdj/dUV0l5wjwSdjeOH2DZtfv8qw==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13205,6 +14280,7 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -13221,6 +14297,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", + "dev": true, "peerDependencies": { "postcss": "^8.0.0" } @@ -13229,6 +14306,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, "dependencies": { "camelcase-css": "^2.0.1" }, @@ -13247,6 +14325,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-4.2.1.tgz", "integrity": "sha512-xuXll4isR03CrQsmxyz92LJB2xX9n+pZJ5jE9JgcnmsCammLyKdlzrBin+25dy6wIjfhJpKBAN80gsTlCgRk2w==", + "dev": true, "dependencies": { "@csstools/postcss-progressive-custom-properties": "^1.1.0", "postcss-value-parser": "^4.2.0" @@ -13266,6 +14345,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.1.tgz", "integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==", + "dev": true, "dependencies": { "lilconfig": "^2.0.5", "yaml": "^2.1.1" @@ -13294,6 +14374,7 @@ "version": "2.2.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz", "integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==", + "dev": true, "engines": { "node": ">= 14" } @@ -13302,6 +14383,7 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", + "dev": true, "dependencies": { "cosmiconfig": "^7.0.0", "klona": "^2.0.5", @@ -13323,6 +14405,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-5.0.4.tgz", "integrity": "sha512-RHXxplCeLh9VjinvMrZONq7im4wjWGlRJAqmAVLXyZaXwfDWP73/oq4NdIp+OZwhQUMj0zjqDfM5Fj7qby+B4g==", + "dev": true, "engines": { "node": "^12 || ^14 || >=16" }, @@ -13334,6 +14417,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/postcss-media-minmax/-/postcss-media-minmax-5.0.0.tgz", "integrity": "sha512-yDUvFf9QdFZTuCUg0g0uNSHVlJ5X1lSzDZjPSFaiCWvjgsvu8vEVxtahPrLMinIDEEGnx6cBe6iqdx5YWz08wQ==", + "dev": true, "engines": { "node": ">=10.0.0" }, @@ -13345,6 +14429,7 @@ "version": "5.1.7", "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0", "stylehacks": "^5.1.1" @@ -13360,6 +14445,7 @@ "version": "5.1.4", "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0", @@ -13377,6 +14463,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13391,6 +14478,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "dev": true, "dependencies": { "colord": "^2.9.1", "cssnano-utils": "^3.1.0", @@ -13407,6 +14495,7 @@ "version": "5.1.4", "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "cssnano-utils": "^3.1.0", @@ -13423,6 +14512,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.5" }, @@ -13437,6 +14527,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, "engines": { "node": "^10 || ^12 || >= 14" }, @@ -13448,6 +14539,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, "dependencies": { "icss-utils": "^5.0.0", "postcss-selector-parser": "^6.0.2", @@ -13464,6 +14556,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.4" }, @@ -13478,6 +14571,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, "dependencies": { "icss-utils": "^5.0.0" }, @@ -13492,6 +14586,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.11" }, @@ -13510,6 +14605,7 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-10.2.0.tgz", "integrity": "sha512-EwMkYchxiDiKUhlJGzWsD9b2zvq/r2SSubcRrgP+jujMXFzqvANLt16lJANC+5uZ6hjI7lpRmI6O8JIl+8l1KA==", + "dev": true, "dependencies": { "@csstools/selector-specificity": "^2.0.0", "postcss-selector-parser": "^6.0.10" @@ -13529,6 +14625,7 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/postcss-normalize/-/postcss-normalize-10.0.1.tgz", "integrity": "sha512-+5w18/rDev5mqERcG3W5GZNMJa1eoYYNGo8gB7tEwaos0ajk3ZXAI4mHGcNT47NE+ZnZD1pEpUOFLvltIwmeJA==", + "dev": true, "dependencies": { "@csstools/normalize.css": "*", "postcss-browser-comments": "^4", @@ -13546,6 +14643,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "dev": true, "engines": { "node": "^10 || ^12 || >=14.0" }, @@ -13557,6 +14655,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13571,6 +14670,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13585,6 +14685,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13599,6 +14700,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13613,6 +14715,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13627,6 +14730,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "postcss-value-parser": "^4.2.0" @@ -13642,6 +14746,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "dev": true, "dependencies": { "normalize-url": "^6.0.1", "postcss-value-parser": "^4.2.0" @@ -13657,6 +14762,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13671,6 +14777,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-1.1.3.tgz", "integrity": "sha512-An6Ba4pHBiDtyVpSLymUUERMo2cU7s+Obz6BTrS+gxkbnSBNKSuD0AVUc+CpBMrpVPKKfoVz0WQCX+Tnst0i4A==", + "dev": true, "funding": [ { "type": "kofi", @@ -13692,6 +14799,7 @@ "version": "5.1.3", "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "dev": true, "dependencies": { "cssnano-utils": "^3.1.0", "postcss-value-parser": "^4.2.0" @@ -13707,6 +14815,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-3.0.4.tgz", "integrity": "sha512-otYl/ylHK8Y9bcBnPLo3foYFLL6a6Ak+3EQBPOTR7luMYCOsiVTUk1iLvNf6tVPNGXcoL9Hoz37kpfriRIFb4A==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13725,6 +14834,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", + "dev": true, "peerDependencies": { "postcss": "^8" } @@ -13733,6 +14843,7 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-7.0.5.tgz", "integrity": "sha512-wR8igaZROA6Z4pv0d+bvVrvGY4GVHihBCBQieXFY3kuSuMyOmEnnfFzHl/tQuqHZkfkIVBEbDvYcFfHmpSet9g==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13751,6 +14862,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.8.3.tgz", "integrity": "sha512-T1LgRm5uEVFSEF83vHZJV2z19lHg4yJuZ6gXZZkqVsqv63nlr6zabMH3l4Pc01FQCyfWVrh2GaUeCVy9Po+Aag==", + "dev": true, "dependencies": { "@csstools/postcss-cascade-layers": "^1.1.1", "@csstools/postcss-color-function": "^1.1.1", @@ -13817,6 +14929,7 @@ "version": "7.1.6", "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-7.1.6.tgz", "integrity": "sha512-9sCtZkO6f/5ML9WcTLcIyV1yz9D1rf0tWc+ulKcvV30s0iZKS/ONyETvoWsr6vnrmW+X+KmuK3gV/w5EWnT37w==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.10" }, @@ -13835,6 +14948,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "caniuse-api": "^3.0.0" @@ -13850,6 +14964,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0" }, @@ -13864,6 +14979,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", + "dev": true, "peerDependencies": { "postcss": "^8.0.3" } @@ -13872,6 +14988,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-6.0.1.tgz", "integrity": "sha512-1i9affjAe9xu/y9uqWH+tD4r6/hDaXJruk8xn2x1vzxC2U3J3LKO3zJW4CyxlNhA56pADJ/djpEwpH1RClI2rQ==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.10" }, @@ -13890,6 +15007,7 @@ "version": "6.0.13", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dev": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13902,6 +15020,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "dev": true, "dependencies": { "postcss-value-parser": "^4.2.0", "svgo": "^2.7.0" @@ -13917,6 +15036,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, "engines": { "node": ">= 10" } @@ -13925,6 +15045,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" @@ -13936,12 +15057,14 @@ "node_modules/postcss-svgo/node_modules/mdn-data": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", - "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true }, "node_modules/postcss-svgo/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -13950,6 +15073,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dev": true, "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", @@ -13970,6 +15094,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.5" }, @@ -13983,12 +15108,14 @@ "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "engines": { "node": ">= 0.8.0" } @@ -13997,6 +15124,7 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, "engines": { "node": ">=6" }, @@ -14008,6 +15136,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dev": true, "dependencies": { "lodash": "^4.17.20", "renderkid": "^3.0.0" @@ -14017,6 +15146,7 @@ "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -14030,6 +15160,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "engines": { "node": ">=10" }, @@ -14040,17 +15171,20 @@ "node_modules/pretty-format/node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true }, "node_modules/promise": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "dev": true, "dependencies": { "asap": "~2.0.6" } @@ -14059,6 +15193,7 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" @@ -14086,6 +15221,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -14098,6 +15234,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, "engines": { "node": ">= 0.10" } @@ -14105,12 +15242,14 @@ "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, "engines": { "node": ">=6" } @@ -14119,6 +15258,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "dev": true, "engines": { "node": ">=0.6.0", "teleport": ">=0.2.0" @@ -14128,6 +15268,7 @@ "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, "dependencies": { "side-channel": "^1.0.4" }, @@ -14141,12 +15282,14 @@ "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -14166,6 +15309,7 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, "dependencies": { "performance-now": "^2.1.0" } @@ -14174,6 +15318,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -14182,6 +15327,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -14190,6 +15336,7 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dev": true, "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -14204,6 +15351,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, "engines": { "node": ">= 0.8" } @@ -14212,6 +15360,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -14234,6 +15383,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz", "integrity": "sha512-sZ41cxiU5llIB003yxxQBYrARBqe0repqPTTYBTmMqTz9szeBbE37BehCE891NZsmdZqqP+xWKdT3eo3vOzN8w==", + "dev": true, "dependencies": { "core-js": "^3.19.2", "object-assign": "^4.1.1", @@ -14250,6 +15400,7 @@ "version": "12.0.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", + "dev": true, "dependencies": { "@babel/code-frame": "^7.16.0", "address": "^1.1.2", @@ -14284,6 +15435,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -14298,6 +15450,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14313,6 +15466,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -14323,12 +15477,14 @@ "node_modules/react-dev-utils/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/react-dev-utils/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -14337,6 +15493,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", + "dev": true, "engines": { "node": ">= 12.13.0" } @@ -14345,6 +15502,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -14367,7 +15525,8 @@ "node_modules/react-error-overlay": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", - "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" + "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==", + "dev": true }, "node_modules/react-i18next": { "version": "11.18.6", @@ -14410,6 +15569,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -14448,6 +15608,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", "integrity": "sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==", + "dev": true, "dependencies": { "@babel/core": "^7.16.0", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", @@ -14535,6 +15696,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, "dependencies": { "pify": "^2.3.0" } @@ -14543,6 +15705,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -14556,6 +15719,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -14567,6 +15731,7 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", + "dev": true, "dependencies": { "minimatch": "^3.0.5" }, @@ -14577,12 +15742,14 @@ "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==" + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true }, "node_modules/regenerate-unicode-properties": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz", "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==", + "dev": true, "dependencies": { "regenerate": "^1.4.2" }, @@ -14599,6 +15766,7 @@ "version": "0.15.1", "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.1.tgz", "integrity": "sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==", + "dev": true, "dependencies": { "@babel/runtime": "^7.8.4" } @@ -14606,12 +15774,14 @@ "node_modules/regex-parser": { "version": "2.2.11", "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.2.11.tgz", - "integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==" + "integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==", + "dev": true }, "node_modules/regexp.prototype.flags": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", "integrity": "sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.2.0", @@ -14628,6 +15798,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "dev": true, "dependencies": { "@babel/regjsgen": "^0.8.0", "regenerate": "^1.4.2", @@ -14644,6 +15815,7 @@ "version": "0.9.1", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dev": true, "dependencies": { "jsesc": "~0.5.0" }, @@ -14655,6 +15827,7 @@ "version": "0.5.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true, "bin": { "jsesc": "bin/jsesc" } @@ -14663,6 +15836,7 @@ "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true, "engines": { "node": ">= 0.10" } @@ -14671,6 +15845,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dev": true, "dependencies": { "css-select": "^4.1.3", "dom-converter": "^0.2.0", @@ -14683,6 +15858,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -14691,6 +15867,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -14698,12 +15875,14 @@ "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", "integrity": "sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==", + "devOptional": true, "dependencies": { "is-core-module": "^2.11.0", "path-parse": "^1.0.7", @@ -14720,6 +15899,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, "dependencies": { "resolve-from": "^5.0.0" }, @@ -14731,6 +15911,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, "engines": { "node": ">=8" } @@ -14739,6 +15920,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "devOptional": true, "engines": { "node": ">=4" } @@ -14747,6 +15929,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz", "integrity": "sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA==", + "dev": true, "dependencies": { "adjust-sourcemap-loader": "^4.0.0", "convert-source-map": "^1.7.0", @@ -14773,12 +15956,14 @@ "node_modules/resolve-url-loader/node_modules/picocolors": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", - "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==" + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true }, "node_modules/resolve-url-loader/node_modules/postcss": { "version": "7.0.39", "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, "dependencies": { "picocolors": "^0.2.1", "source-map": "^0.6.1" @@ -14795,6 +15980,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -14803,6 +15989,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.1.tgz", "integrity": "sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ==", + "dev": true, "engines": { "node": ">=10" } @@ -14811,6 +15998,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, "engines": { "node": ">= 4" } @@ -14819,6 +16007,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -14828,6 +16017,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -14842,6 +16032,7 @@ "version": "2.79.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "dev": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -14857,6 +16048,7 @@ "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser", + "dev": true, "dependencies": { "@babel/code-frame": "^7.10.4", "jest-worker": "^26.2.1", @@ -14871,6 +16063,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -14879,6 +16072,7 @@ "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dev": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -14892,6 +16086,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, "dependencies": { "randombytes": "^2.1.0" } @@ -14900,6 +16095,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -14911,6 +16107,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -14933,6 +16130,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.0.tgz", "integrity": "sha512-9dVEFruWIsnie89yym+xWTAYASdpw3CJV7Li/6zBewGf9z2i1j31rP6jnY0pHEO4QZh6N0K11bFjWmdR8UGdPQ==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.2.0", @@ -14950,6 +16148,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -14969,6 +16168,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "get-intrinsic": "^1.1.3", @@ -14981,17 +16181,20 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true }, "node_modules/sanitize.css": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", - "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==" + "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==", + "dev": true }, "node_modules/sass-loader": { "version": "12.6.0", "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", + "dev": true, "dependencies": { "klona": "^2.0.4", "neo-async": "^2.6.2" @@ -15028,12 +16231,14 @@ "node_modules/sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true }, "node_modules/saxes": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dev": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -15053,6 +16258,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.2.tgz", "integrity": "sha512-pvjEHOgWc9OWA/f/DE3ohBWTD6EleVLf7iFUkoSwAxttdBhB9QUebQgxER2kWueOvRJXPHNnyrvvh9eZINB8Eg==", + "dev": true, "dependencies": { "@types/json-schema": "^7.0.8", "ajv": "^6.12.5", @@ -15069,12 +16275,14 @@ "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==" + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true }, "node_modules/selfsigned": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.1.1.tgz", "integrity": "sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ==", + "dev": true, "dependencies": { "node-forge": "^1" }, @@ -15086,6 +16294,7 @@ "version": "7.5.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz", "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==", + "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -15100,6 +16309,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -15110,12 +16320,14 @@ "node_modules/semver/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/send": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -15139,6 +16351,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "dependencies": { "ms": "2.0.0" } @@ -15146,17 +16359,20 @@ "node_modules/send/node_modules/debug/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true }, "node_modules/send/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true }, "node_modules/serialize-javascript": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dev": true, "dependencies": { "randombytes": "^2.1.0" } @@ -15165,6 +16381,7 @@ "version": "1.9.1", "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, "dependencies": { "accepts": "~1.3.4", "batch": "0.6.1", @@ -15182,6 +16399,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "dependencies": { "ms": "2.0.0" } @@ -15190,6 +16408,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -15198,6 +16417,7 @@ "version": "1.6.3", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, "dependencies": { "depd": "~1.1.2", "inherits": "2.0.3", @@ -15211,22 +16431,26 @@ "node_modules/serve-index/node_modules/inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true }, "node_modules/serve-index/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true }, "node_modules/serve-index/node_modules/setprototypeof": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true }, "node_modules/serve-index/node_modules/statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, "engines": { "node": ">= 0.6" } @@ -15235,6 +16459,7 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dev": true, "dependencies": { "encodeurl": "~1.0.2", "escape-html": "~1.0.3", @@ -15248,12 +16473,14 @@ "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -15265,6 +16492,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "engines": { "node": ">=8" } @@ -15273,6 +16501,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -15281,6 +16510,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, "dependencies": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", @@ -15293,17 +16523,20 @@ "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, "engines": { "node": ">=8" } @@ -15312,6 +16545,7 @@ "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, "dependencies": { "faye-websocket": "^0.11.3", "uuid": "^8.3.2", @@ -15321,7 +16555,8 @@ "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", - "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==" + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "dev": true }, "node_modules/source-map": { "version": "0.5.7", @@ -15337,6 +16572,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -15345,6 +16581,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.2.tgz", "integrity": "sha512-BokxPoLjyl3iOrgkWaakaxqnelAJSS+0V+De0kKIq6lyWrXuiPgYTGp6z3iHmqljKAaLXwZa+ctD8GccRJeVvg==", + "dev": true, "dependencies": { "abab": "^2.0.5", "iconv-lite": "^0.6.3", @@ -15365,6 +16602,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -15374,6 +16612,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -15382,12 +16621,14 @@ "version": "1.4.8", "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead" + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true }, "node_modules/spdy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, "dependencies": { "debug": "^4.1.0", "handle-thing": "^2.0.0", @@ -15403,6 +16644,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, "dependencies": { "debug": "^4.1.0", "detect-node": "^2.0.4", @@ -15415,13 +16657,15 @@ "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true }, "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", - "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility" + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", + "dev": true }, "node_modules/stack-generator": { "version": "2.0.10", @@ -15435,6 +16679,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, "dependencies": { "escape-string-regexp": "^2.0.0" }, @@ -15446,6 +16691,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, "engines": { "node": ">=8" } @@ -15486,6 +16732,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, "engines": { "node": ">= 0.8" } @@ -15494,6 +16741,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", + "dev": true, "dependencies": { "internal-slot": "^1.0.4" }, @@ -15505,6 +16753,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -15513,6 +16762,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" @@ -15524,12 +16774,14 @@ "node_modules/string-natural-compare": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", - "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==" + "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==", + "dev": true }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -15542,12 +16794,14 @@ "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true }, "node_modules/string.prototype.matchall": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", "integrity": "sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -15566,6 +16820,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz", "integrity": "sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -15582,6 +16837,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -15595,6 +16851,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "define-properties": "^1.1.4", @@ -15608,6 +16865,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", "is-obj": "^1.0.1", @@ -15621,6 +16879,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -15632,6 +16891,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, "engines": { "node": ">=8" } @@ -15640,6 +16900,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, "engines": { "node": ">=10" } @@ -15648,6 +16909,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, "engines": { "node": ">=6" } @@ -15656,6 +16918,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "engines": { "node": ">=8" }, @@ -15667,6 +16930,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.2.tgz", "integrity": "sha512-RHs/vcrKdQK8wZliteNK4NKzxvLBzpuHMqYmUVWeKa6MkaIQ97ZTOS0b+zapZhy6GcrgWnvWYCMHRirC3FsUmw==", + "dev": true, "engines": { "node": ">= 12.13.0" }, @@ -15682,6 +16946,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", + "dev": true, "dependencies": { "browserslist": "^4.21.4", "postcss-selector-parser": "^6.0.4" @@ -15702,6 +16967,7 @@ "version": "3.32.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.32.0.tgz", "integrity": "sha512-ydQOU34rpSyj2TGyz4D2p8rbktIOZ8QY9s+DGLvFU1i5pWJE8vkpruCjGCMHsdXwnD7JDcS+noSwM/a7zyNFDQ==", + "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", @@ -15723,6 +16989,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, "engines": { "node": ">= 6" } @@ -15731,6 +16998,7 @@ "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -15750,6 +17018,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "devOptional": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -15761,6 +17030,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" @@ -15773,6 +17043,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { "node": ">=8" } @@ -15781,6 +17052,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -15792,6 +17064,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "devOptional": true, "engines": { "node": ">= 0.4" }, @@ -15802,13 +17075,15 @@ "node_modules/svg-parser": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true }, "node_modules/svgo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", "integrity": "sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==", "deprecated": "This SVGO version is no longer supported. Upgrade to v2.x.x.", + "dev": true, "dependencies": { "chalk": "^2.4.1", "coa": "^2.0.2", @@ -15835,6 +17110,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", "integrity": "sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==", + "dev": true, "dependencies": { "boolbase": "^1.0.0", "css-what": "^3.2.1", @@ -15846,6 +17122,7 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz", "integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==", + "dev": true, "engines": { "node": ">= 6" }, @@ -15857,6 +17134,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "dev": true, "dependencies": { "domelementtype": "^2.0.1", "entities": "^2.0.0" @@ -15866,6 +17144,7 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "dev": true, "dependencies": { "dom-serializer": "0", "domelementtype": "1" @@ -15874,12 +17153,14 @@ "node_modules/svgo/node_modules/domutils/node_modules/domelementtype": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", - "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==" + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "dev": true }, "node_modules/svgo/node_modules/nth-check": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "dev": true, "dependencies": { "boolbase": "~1.0.0" } @@ -15887,12 +17168,14 @@ "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true }, "node_modules/tailwindcss": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.2.tgz", "integrity": "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==", + "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -15930,6 +17213,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, "engines": { "node": ">=6" } @@ -15938,6 +17222,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, "engines": { "node": ">=8" } @@ -15946,6 +17231,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, "dependencies": { "is-stream": "^2.0.0", "temp-dir": "^2.0.0", @@ -15963,6 +17249,7 @@ "version": "0.16.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, "engines": { "node": ">=10" }, @@ -15974,6 +17261,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" @@ -15989,6 +17277,7 @@ "version": "5.17.4", "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.4.tgz", "integrity": "sha512-jcEKZw6UPrgugz/0Tuk/PVyLAPfMBJf5clnGueo45wTweoV8yh7Q7PEkhkJ5uuUbC7zAxEcG3tqNr1bstkQ8nw==", + "dev": true, "dependencies": { "@jridgewell/source-map": "^0.3.2", "acorn": "^8.5.0", @@ -16006,6 +17295,7 @@ "version": "5.3.9", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", + "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.17", "jest-worker": "^27.4.5", @@ -16038,12 +17328,14 @@ "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", @@ -16056,12 +17348,14 @@ "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, "dependencies": { "any-promise": "^1.0.0" } @@ -16070,6 +17364,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, "dependencies": { "thenify": ">= 3.1.0 < 4" }, @@ -16080,7 +17375,8 @@ "node_modules/throat": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", - "integrity": "sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==" + "integrity": "sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==", + "dev": true }, "node_modules/throttle-debounce": { "version": "2.3.0", @@ -16093,17 +17389,20 @@ "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==" + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "devOptional": true, "engines": { "node": ">=4" } @@ -16112,6 +17411,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -16123,6 +17423,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, "engines": { "node": ">=0.6" } @@ -16131,6 +17432,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", + "dev": true, "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -16145,6 +17447,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, "engines": { "node": ">= 4.0.0" } @@ -16157,17 +17460,20 @@ "node_modules/tryer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", - "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==" + "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", + "dev": true }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true }, "node_modules/tsconfig-paths": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz", "integrity": "sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==", + "dev": true, "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", @@ -16179,6 +17485,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, "dependencies": { "minimist": "^1.2.0" }, @@ -16190,6 +17497,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, "engines": { "node": ">=4" } @@ -16197,12 +17505,14 @@ "node_modules/tslib": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.1.tgz", - "integrity": "sha512-KaI6gPil5m9vF7DKaoXxx1ia9fxS4qG5YveErRRVknPDXXriu5M8h48YRjB6h5ZUOKuAKlSJYb0GaDe8I39fRw==" + "integrity": "sha512-KaI6gPil5m9vF7DKaoXxx1ia9fxS4qG5YveErRRVknPDXXriu5M8h48YRjB6h5ZUOKuAKlSJYb0GaDe8I39fRw==", + "dev": true }, "node_modules/tsutils": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, "dependencies": { "tslib": "^1.8.1" }, @@ -16216,12 +17526,14 @@ "node_modules/tsutils/node_modules/tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -16233,6 +17545,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, "engines": { "node": ">=4" } @@ -16241,6 +17554,7 @@ "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, "engines": { "node": ">=10" }, @@ -16252,6 +17566,7 @@ "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" @@ -16264,6 +17579,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "for-each": "^0.3.3", @@ -16277,6 +17593,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, "dependencies": { "is-typedarray": "^1.0.0" } @@ -16285,6 +17602,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, "peer": true, "bin": { "tsc": "bin/tsc", @@ -16298,6 +17616,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-bigints": "^1.0.2", @@ -16312,6 +17631,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "dev": true, "engines": { "node": ">=4" } @@ -16320,6 +17640,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" @@ -16332,6 +17653,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "dev": true, "engines": { "node": ">=4" } @@ -16340,6 +17662,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, "engines": { "node": ">=4" } @@ -16348,6 +17671,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, "dependencies": { "crypto-random-string": "^2.0.0" }, @@ -16359,6 +17683,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, "engines": { "node": ">= 10.0.0" } @@ -16367,6 +17692,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, "engines": { "node": ">= 0.8" } @@ -16374,12 +17700,14 @@ "node_modules/unquote": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", - "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==" + "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==", + "dev": true }, "node_modules/upath": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, "engines": { "node": ">=4", "yarn": "*" @@ -16389,6 +17717,7 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -16418,6 +17747,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -16426,6 +17756,7 @@ "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -16434,12 +17765,14 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true }, "node_modules/util.promisify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", + "dev": true, "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.2", @@ -16453,12 +17786,14 @@ "node_modules/utila": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", - "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==" + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "dev": true }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, "engines": { "node": ">= 0.4.0" } @@ -16467,6 +17802,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, "bin": { "uuid": "dist/bin/uuid" } @@ -16475,6 +17811,7 @@ "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", + "dev": true, "dependencies": { "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^1.6.0", @@ -16488,6 +17825,7 @@ "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, "engines": { "node": ">= 8" } @@ -16496,6 +17834,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, "engines": { "node": ">= 0.8" } @@ -16513,6 +17852,7 @@ "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", + "dev": true, "dependencies": { "browser-process-hrtime": "^1.0.0" } @@ -16521,6 +17861,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "dev": true, "dependencies": { "xml-name-validator": "^3.0.0" }, @@ -16532,6 +17873,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, "dependencies": { "makeerror": "1.0.12" } @@ -16540,6 +17882,7 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -16552,6 +17895,7 @@ "version": "1.7.3", "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, "dependencies": { "minimalistic-assert": "^1.0.0" } @@ -16560,6 +17904,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "dev": true, "engines": { "node": ">=10.4" } @@ -16568,6 +17913,7 @@ "version": "5.83.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.83.1.tgz", "integrity": "sha512-TNsG9jDScbNuB+Lb/3+vYolPplCS3bbEaJf+Bj0Gw4DhP3ioAflBb1flcRt9zsWITyvOhM96wMQNRWlSX52DgA==", + "dev": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", @@ -16614,6 +17960,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "dev": true, "dependencies": { "colorette": "^2.0.10", "memfs": "^3.4.3", @@ -16636,6 +17983,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -16651,6 +17999,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -16661,12 +18010,14 @@ "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true }, "node_modules/webpack-dev-middleware/node_modules/schema-utils": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.1.tgz", "integrity": "sha512-lELhBAAly9NowEsX0yZBlw9ahZG+sK/1RJ21EpzdYHKEs13Vku3LJ+MIPhh4sMs0oCCeufZQEQbMekiA4vuVIQ==", + "dev": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -16685,6 +18036,7 @@ "version": "4.15.0", "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.0.tgz", "integrity": "sha512-HmNB5QeSl1KpulTBQ8UT4FPrByYyaLxpJoQ0+s7EvUrMc16m0ZS1sgb1XGqzmgCPk0c9y+aaXxn11tbLzuM7NQ==", + "dev": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -16743,6 +18095,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -16758,6 +18111,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -16768,12 +18122,14 @@ "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true }, "node_modules/webpack-dev-server/node_modules/schema-utils": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.1.tgz", "integrity": "sha512-lELhBAAly9NowEsX0yZBlw9ahZG+sK/1RJ21EpzdYHKEs13Vku3LJ+MIPhh4sMs0oCCeufZQEQbMekiA4vuVIQ==", + "dev": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -16792,6 +18148,7 @@ "version": "8.13.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "dev": true, "engines": { "node": ">=10.0.0" }, @@ -16812,6 +18169,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz", "integrity": "sha512-YXUAwxtfKIJIKkhg03MKuiFAD72PlrqCiwdwO4VEXdRO5V0ORCNwaOwAZawPZalCbmH9kBDmXnNeQOw+BIEiow==", + "dev": true, "dependencies": { "tapable": "^2.0.0", "webpack-sources": "^2.2.0" @@ -16827,6 +18185,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -16835,6 +18194,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.1.tgz", "integrity": "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==", + "dev": true, "dependencies": { "source-list-map": "^2.0.1", "source-map": "^0.6.1" @@ -16847,6 +18207,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, "engines": { "node": ">=10.13.0" } @@ -16855,6 +18216,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -16867,6 +18229,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, "engines": { "node": ">=4.0" } @@ -16875,6 +18238,7 @@ "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, "dependencies": { "http-parser-js": ">=0.5.1", "safe-buffer": ">=5.1.0", @@ -16888,6 +18252,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, "engines": { "node": ">=0.8.0" } @@ -16896,6 +18261,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "dev": true, "dependencies": { "iconv-lite": "0.4.24" } @@ -16904,6 +18270,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -16914,12 +18281,14 @@ "node_modules/whatwg-fetch": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", - "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==" + "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==", + "dev": true }, "node_modules/whatwg-mimetype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", - "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==" + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "dev": true }, "node_modules/whatwg-url": { "version": "5.0.0", @@ -16939,6 +18308,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -16953,6 +18323,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, "dependencies": { "is-bigint": "^1.0.1", "is-boolean-object": "^1.1.0", @@ -16968,6 +18339,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "dev": true, "dependencies": { "is-map": "^2.0.1", "is-set": "^2.0.1", @@ -16982,6 +18354,7 @@ "version": "1.1.9", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", + "dev": true, "dependencies": { "available-typed-arrays": "^1.0.5", "call-bind": "^1.0.2", @@ -17001,6 +18374,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -17009,6 +18383,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.5.4.tgz", "integrity": "sha512-0r4INQZMyPky/lj4Ou98qxcThrETucOde+7mRGJl13MPJugQNKeZQOdIJe/1AchOP23cTqHcN/YVpD6r8E6I8g==", + "dev": true, "dependencies": { "idb": "^7.0.1", "workbox-core": "6.5.4" @@ -17018,6 +18393,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.5.4.tgz", "integrity": "sha512-I/lBERoH1u3zyBosnpPEtcAVe5lwykx9Yg1k6f8/BGEPGaMMgZrwVrqL1uA9QZ1NGGFoyE6t9i7lBjOlDhFEEw==", + "dev": true, "dependencies": { "workbox-core": "6.5.4" } @@ -17026,6 +18402,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-6.5.4.tgz", "integrity": "sha512-kgRevLXEYvUW9WS4XoziYqZ8Q9j/2ziJYEtTrjdz5/L/cTUa2XfyMP2i7c3p34lgqJ03+mTiz13SdFef2POwbA==", + "dev": true, "dependencies": { "@apideck/better-ajv-errors": "^0.3.1", "@babel/core": "^7.11.1", @@ -17073,6 +18450,7 @@ "version": "0.3.6", "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", + "dev": true, "dependencies": { "json-schema": "^0.4.0", "jsonpointer": "^5.0.0", @@ -17089,6 +18467,7 @@ "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -17104,6 +18483,7 @@ "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -17117,12 +18497,14 @@ "node_modules/workbox-build/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true }, "node_modules/workbox-build/node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "dev": true, "dependencies": { "whatwg-url": "^7.0.0" }, @@ -17134,6 +18516,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -17141,12 +18524,14 @@ "node_modules/workbox-build/node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==" + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true }, "node_modules/workbox-build/node_modules/whatwg-url": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", @@ -17157,6 +18542,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-6.5.4.tgz", "integrity": "sha512-DCR9uD0Fqj8oB2TSWQEm1hbFs/85hXXoayVwFKLVuIuxwJaihBsLsp4y7J9bvZbqtPJ1KlCkmYVGQKrBU4KAug==", + "dev": true, "dependencies": { "workbox-core": "6.5.4" } @@ -17164,12 +18550,14 @@ "node_modules/workbox-core": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-6.5.4.tgz", - "integrity": "sha512-OXYb+m9wZm8GrORlV2vBbE5EC1FKu71GGp0H4rjmxmF4/HLbMCoTFws87M3dFwgpmg0v00K++PImpNQ6J5NQ6Q==" + "integrity": "sha512-OXYb+m9wZm8GrORlV2vBbE5EC1FKu71GGp0H4rjmxmF4/HLbMCoTFws87M3dFwgpmg0v00K++PImpNQ6J5NQ6Q==", + "dev": true }, "node_modules/workbox-expiration": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-6.5.4.tgz", "integrity": "sha512-jUP5qPOpH1nXtjGGh1fRBa1wJL2QlIb5mGpct3NzepjGG2uFFBn4iiEBiI9GUmfAFR2ApuRhDydjcRmYXddiEQ==", + "dev": true, "dependencies": { "idb": "^7.0.1", "workbox-core": "6.5.4" @@ -17179,6 +18567,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.5.4.tgz", "integrity": "sha512-8AU1WuaXsD49249Wq0B2zn4a/vvFfHkpcFfqAFHNHwln3jK9QUYmzdkKXGIZl9wyKNP+RRX30vcgcyWMcZ9VAg==", + "dev": true, "dependencies": { "workbox-background-sync": "6.5.4", "workbox-core": "6.5.4", @@ -17190,6 +18579,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-6.5.4.tgz", "integrity": "sha512-IIwf80eO3cr8h6XSQJF+Hxj26rg2RPFVUmJLUlM0+A2GzB4HFbQyKkrgD5y2d84g2IbJzP4B4j5dPBRzamHrng==", + "dev": true, "dependencies": { "workbox-core": "6.5.4" } @@ -17198,6 +18588,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-6.5.4.tgz", "integrity": "sha512-hSMezMsW6btKnxHB4bFy2Qfwey/8SYdGWvVIKFaUm8vJ4E53JAY+U2JwLTRD8wbLWoP6OVUdFlXsTdKu9yoLTg==", + "dev": true, "dependencies": { "workbox-core": "6.5.4", "workbox-routing": "6.5.4", @@ -17208,6 +18599,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-6.5.4.tgz", "integrity": "sha512-Je2qR1NXCFC8xVJ/Lux6saH6IrQGhMpDrPXWZWWS8n/RD+WZfKa6dSZwU+/QksfEadJEr/NfY+aP/CXFFK5JFg==", + "dev": true, "dependencies": { "workbox-core": "6.5.4" } @@ -17216,6 +18608,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-6.5.4.tgz", "integrity": "sha512-QZNO8Ez708NNwzLNEXTG4QYSKQ1ochzEtRLGaq+mr2PyoEIC1xFW7MrWxrONUxBFOByksds9Z4//lKAX8tHyUA==", + "dev": true, "dependencies": { "workbox-cacheable-response": "6.5.4", "workbox-core": "6.5.4", @@ -17229,6 +18622,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-6.5.4.tgz", "integrity": "sha512-apQswLsbrrOsBUWtr9Lf80F+P1sHnQdYodRo32SjiByYi36IDyL2r7BH1lJtFX8fwNHDa1QOVY74WKLLS6o5Pg==", + "dev": true, "dependencies": { "workbox-core": "6.5.4" } @@ -17237,6 +18631,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-6.5.4.tgz", "integrity": "sha512-DEtsxhx0LIYWkJBTQolRxG4EI0setTJkqR4m7r4YpBdxtWJH1Mbg01Cj8ZjNOO8etqfA3IZaOPHUxCs8cBsKLw==", + "dev": true, "dependencies": { "workbox-core": "6.5.4" } @@ -17245,6 +18640,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-6.5.4.tgz", "integrity": "sha512-FXKVh87d2RFXkliAIheBojBELIPnWbQdyDvsH3t74Cwhg0fDheL1T8BqSM86hZvC0ZESLsznSYWw+Va+KVbUzg==", + "dev": true, "dependencies": { "workbox-core": "6.5.4", "workbox-routing": "6.5.4" @@ -17253,12 +18649,14 @@ "node_modules/workbox-sw": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-6.5.4.tgz", - "integrity": "sha512-vo2RQo7DILVRoH5LjGqw3nphavEjK4Qk+FenXeUsknKn14eCNedHOXWbmnvP4ipKhlE35pvJ4yl4YYf6YsJArA==" + "integrity": "sha512-vo2RQo7DILVRoH5LjGqw3nphavEjK4Qk+FenXeUsknKn14eCNedHOXWbmnvP4ipKhlE35pvJ4yl4YYf6YsJArA==", + "dev": true }, "node_modules/workbox-webpack-plugin": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-webpack-plugin/-/workbox-webpack-plugin-6.5.4.tgz", "integrity": "sha512-LmWm/zoaahe0EGmMTrSLUi+BjyR3cdGEfU3fS6PN1zKFYbqAKuQ+Oy/27e4VSXsyIwAw8+QDfk1XHNGtZu9nQg==", + "dev": true, "dependencies": { "fast-json-stable-stringify": "^2.1.0", "pretty-bytes": "^5.4.1", @@ -17277,6 +18675,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -17285,6 +18684,7 @@ "version": "1.4.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "dev": true, "dependencies": { "source-list-map": "^2.0.0", "source-map": "~0.6.1" @@ -17294,6 +18694,7 @@ "version": "6.5.4", "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-6.5.4.tgz", "integrity": "sha512-HnLZJDwYBE+hpG25AQBO8RUWBJRaCsI9ksQJEp3aCOFCaG5kqaToAYXFRAHxzRluM2cQbGzdQF5rjKPWPA1fug==", + "dev": true, "dependencies": { "@types/trusted-types": "^2.0.2", "workbox-core": "6.5.4" @@ -17303,6 +18704,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -17319,6 +18721,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -17333,6 +18736,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -17343,17 +18747,20 @@ "node_modules/wrap-ansi/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true }, "node_modules/write-file-atomic": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, "dependencies": { "imurmurhash": "^0.1.4", "is-typedarray": "^1.0.0", @@ -17365,6 +18772,7 @@ "version": "7.5.9", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "dev": true, "engines": { "node": ">=8.3.0" }, @@ -17384,17 +18792,20 @@ "node_modules/xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", - "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==" + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "dev": true }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "engines": { "node": ">=10" } @@ -17402,12 +18813,14 @@ "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "devOptional": true, "engines": { "node": ">= 6" } @@ -17416,6 +18829,7 @@ "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -17433,6 +18847,7 @@ "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, "engines": { "node": ">=10" } @@ -17441,6 +18856,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "engines": { "node": ">=10" }, diff --git a/web/package.json b/web/package.json index f26eba9..a4a6907 100644 --- a/web/package.json +++ b/web/package.json @@ -23,10 +23,12 @@ "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", "react-router-dom": "^6.2.2", - "react-scripts": "^5.0.0", "stacktrace-gps": "^3.0.4", "stacktrace-js": "^2.0.2" }, + "devDependencies": { + "react-scripts": "^5.0.0" + }, "browserslist": { "production": [ ">0.2%", From 3f8784c8a87e4b5d0345a156bf91ad1b5e3bc254 Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Tue, 23 May 2023 21:03:58 +0200 Subject: [PATCH 50/97] Move checkout up since the cache needs lockfiles --- .github/workflows/build.yaml | 6 +++--- .github/workflows/release.yaml | 6 +++--- .github/workflows/test.yaml | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f9fc481..0076c0f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -4,6 +4,9 @@ jobs: build: runs-on: ubuntu-latest steps: + - + name: Checkout code + uses: actions/checkout@v3 - name: Install Go uses: actions/setup-go@v4 @@ -16,9 +19,6 @@ jobs: node-version: '18' cache: 'npm' cache-dependency-path: './web/package-lock.json' - - - name: Checkout code - uses: actions/checkout@v3 - name: Install dependencies run: make build-deps-ubuntu diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index e6e30e0..f709332 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -7,6 +7,9 @@ jobs: release: runs-on: ubuntu-latest steps: + - + name: Checkout code + uses: actions/checkout@v3 - name: Install Go uses: actions/setup-go@v4 @@ -19,9 +22,6 @@ jobs: node-version: '18' cache: 'npm' cache-dependency-path: './web/package-lock.json' - - - name: Checkout code - uses: actions/checkout@v3 - name: Docker login uses: docker/login-action@v2 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f04162c..7473567 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,6 +4,9 @@ jobs: test: runs-on: ubuntu-latest steps: + - + name: Checkout code + uses: actions/checkout@v3 - name: Install Go uses: actions/setup-go@v4 @@ -16,9 +19,6 @@ jobs: node-version: '18' cache: 'npm' cache-dependency-path: './web/package-lock.json' - - - name: Checkout code - uses: actions/checkout@v3 - name: Install dependencies run: make build-deps-ubuntu From 206ea312bfadf6f73439673c1383dbfafca2b673 Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Tue, 23 May 2023 20:54:00 +0200 Subject: [PATCH 51/97] Add prettier --- Makefile | 9 ++++++++- web/.prettierignore | 2 ++ web/package-lock.json | 16 ++++++++++++++++ web/package.json | 5 ++++- 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 web/.prettierignore diff --git a/Makefile b/Makefile index 76f46a8..6786acb 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,8 @@ help: @echo " make web - Build the web app" @echo " make web-deps - Install web app dependencies (npm install the universe)" @echo " make web-build - Actually build the web app" + @echo " make web-format - Run prettier on the web app + @echo " make web-format-check - Run prettier on the web app, but don't change anything @echo @echo "Build documentation:" @echo " make docs - Build the documentation" @@ -137,6 +139,11 @@ web-deps: web-deps-update: cd web && npm update +web-format: + cd web && npm run format + +web-format-check: + cd web && npm run format:check # Main server/client build @@ -226,7 +233,7 @@ cli-build-results: # Test/check targets -check: test fmt-check vet lint staticcheck +check: test web-format-check fmt-check vet lint staticcheck test: .PHONY go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..d0097d3 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,2 @@ +build/ +public/static/langs/ \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json index a233aee..d830d63 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -26,6 +26,7 @@ "stacktrace-js": "^2.0.2" }, "devDependencies": { + "prettier": "^2.8.8", "react-scripts": "^5.0.0" } }, @@ -15120,6 +15121,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", diff --git a/web/package.json b/web/package.json index a4a6907..1ca2da7 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,9 @@ "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", - "eject": "react-scripts eject" + "eject": "react-scripts eject", + "format": "prettier . --write", + "format:check": "prettier . --check" }, "dependencies": { "@mui/icons-material": "^5.4.2", @@ -27,6 +29,7 @@ "stacktrace-js": "^2.0.2" }, "devDependencies": { + "prettier": "^2.8.8", "react-scripts": "^5.0.0" }, "browserslist": { From 6f6a2d1f693070bf72e89d86748080e4825c9164 Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Tue, 23 May 2023 21:13:01 +0200 Subject: [PATCH 52/97] Run prettier --- web/public/config.js | 30 +- web/public/index.html | 90 +- web/public/static/css/app.css | 9 +- web/public/static/css/fonts.css | 36 +- web/src/app/AccountApi.js | 777 +- web/src/app/Api.js | 213 +- web/src/app/Connection.js | 228 +- web/src/app/ConnectionManager.js | 214 +- web/src/app/Notifier.js | 157 +- web/src/app/Poller.js | 86 +- web/src/app/Prefs.js | 42 +- web/src/app/Pruner.js | 49 +- web/src/app/Session.js | 42 +- web/src/app/SubscriptionManager.js | 337 +- web/src/app/UserManager.js | 60 +- web/src/app/config.js | 2 +- web/src/app/db.js | 12 +- web/src/app/emojis.js | 14499 ++++++++++++++++++++- web/src/app/errors.js | 96 +- web/src/app/utils.js | 424 +- web/src/components/Account.js | 2167 +-- web/src/components/ActionBar.js | 332 +- web/src/components/App.js | 270 +- web/src/components/AttachmentIcon.js | 72 +- web/src/components/AvatarBox.js | 46 +- web/src/components/DialogFooter.js | 48 +- web/src/components/EmojiPicker.js | 314 +- web/src/components/ErrorBoundary.js | 245 +- web/src/components/Login.js | 223 +- web/src/components/Messaging.js | 219 +- web/src/components/Navigation.js | 749 +- web/src/components/Notifications.js | 1091 +- web/src/components/PopupMenu.js | 84 +- web/src/components/Pref.js | 89 +- web/src/components/Preferences.js | 1324 +- web/src/components/PublishDialog.js | 1646 ++- web/src/components/ReserveDialogs.js | 392 +- web/src/components/ReserveIcons.js | 67 +- web/src/components/ReserveTopicSelect.js | 103 +- web/src/components/Signup.js | 287 +- web/src/components/SubscribeDialog.js | 631 +- web/src/components/SubscriptionPopup.js | 617 +- web/src/components/UpgradeDialog.js | 785 +- web/src/components/hooks.js | 227 +- web/src/components/i18n.js | 34 +- web/src/components/routes.js | 26 +- web/src/components/styles.js | 14 +- web/src/components/theme.js | 22 +- web/src/index.js | 8 +- 49 files changed, 22902 insertions(+), 6633 deletions(-) diff --git a/web/public/config.js b/web/public/config.js index 5909be4..a748dd8 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -6,14 +6,24 @@ // During web development, you may change values here for rapid testing. var config = { - base_url: window.location.origin, // Change to test against a different server - app_root: "/app", - enable_login: true, - enable_signup: true, - enable_payments: false, - enable_reservations: true, - enable_emails: true, - enable_calls: true, - billing_contact: "", - disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"] + base_url: window.location.origin, // Change to test against a different server + app_root: "/app", + enable_login: true, + enable_signup: true, + enable_payments: false, + enable_reservations: true, + enable_emails: true, + enable_calls: true, + billing_contact: "", + disallowed_topics: [ + "docs", + "static", + "file", + "app", + "account", + "settings", + "signup", + "login", + "v1", + ], }; diff --git a/web/public/index.html b/web/public/index.html index dfec166..31dd280 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -1,44 +1,64 @@ - - - ntfy web + + + ntfy web - - - - + + + + - - - - + + + + - - + + - - - - - - - - + + + + + + + + - - + + - - - - - - -
- - + + + + + + +
+ + diff --git a/web/public/static/css/app.css b/web/public/static/css/app.css index 12b105a..213859c 100644 --- a/web/public/static/css/app.css +++ b/web/public/static/css/app.css @@ -1,10 +1,11 @@ /* web app styling overrides */ -a, a:visited { - color: #338574; +a, +a:visited { + color: #338574; } a:hover { - text-decoration: none; - color: #317f6f; + text-decoration: none; + color: #317f6f; } diff --git a/web/public/static/css/fonts.css b/web/public/static/css/fonts.css index 4245d0f..2cf00a3 100644 --- a/web/public/static/css/fonts.css +++ b/web/public/static/css/fonts.css @@ -2,36 +2,32 @@ /* roboto-300 - latin */ @font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 300; - src: local(''), - url('../fonts/roboto-v29-latin-300.woff2') format('woff2'); + font-family: "Roboto"; + font-style: normal; + font-weight: 300; + src: local(""), url("../fonts/roboto-v29-latin-300.woff2") format("woff2"); } /* roboto-regular - latin */ @font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 400; - src: local(''), - url('../fonts/roboto-v29-latin-regular.woff2') format('woff2'); + font-family: "Roboto"; + font-style: normal; + font-weight: 400; + src: local(""), url("../fonts/roboto-v29-latin-regular.woff2") format("woff2"); } /* roboto-500 - latin */ @font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 500; - src: local(''), - url('../fonts/roboto-v29-latin-500.woff2') format('woff2'); + font-family: "Roboto"; + font-style: normal; + font-weight: 500; + src: local(""), url("../fonts/roboto-v29-latin-500.woff2") format("woff2"); } /* roboto-700 - latin */ @font-face { - font-family: 'Roboto'; - font-style: normal; - font-weight: 700; - src: local(''), - url('../fonts/roboto-v29-latin-700.woff2') format('woff2'); + font-family: "Roboto"; + font-style: normal; + font-weight: 700; + src: local(""), url("../fonts/roboto-v29-latin-700.woff2") format("woff2"); } diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 915e3bb..3f11611 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -1,429 +1,442 @@ import { - accountBillingPortalUrl, - accountBillingSubscriptionUrl, - accountPasswordUrl, - accountPhoneUrl, - accountPhoneVerifyUrl, - accountReservationSingleUrl, - accountReservationUrl, - accountSettingsUrl, - accountSubscriptionUrl, - accountTokenUrl, - accountUrl, - maybeWithBearerAuth, - tiersUrl, - withBasicAuth, - withBearerAuth + accountBillingPortalUrl, + accountBillingSubscriptionUrl, + accountPasswordUrl, + accountPhoneUrl, + accountPhoneVerifyUrl, + accountReservationSingleUrl, + accountReservationUrl, + accountSettingsUrl, + accountSubscriptionUrl, + accountTokenUrl, + accountUrl, + maybeWithBearerAuth, + tiersUrl, + withBasicAuth, + withBearerAuth, } from "./utils"; import session from "./Session"; import subscriptionManager from "./SubscriptionManager"; import i18n from "i18next"; import prefs from "./Prefs"; import routes from "../components/routes"; -import {fetchOrThrow, UnauthorizedError} from "./errors"; +import { fetchOrThrow, UnauthorizedError } from "./errors"; const delayMillis = 45000; // 45 seconds const intervalMillis = 900000; // 15 minutes class AccountApi { - constructor() { - this.timer = null; - this.listener = null; // Fired when account is fetched from remote - this.tiers = null; // Cached - } + constructor() { + this.timer = null; + this.listener = null; // Fired when account is fetched from remote + this.tiers = null; // Cached + } - registerListener(listener) { - this.listener = listener; - } + registerListener(listener) { + this.listener = listener; + } - resetListener() { - this.listener = null; - } + resetListener() { + this.listener = null; + } - async login(user) { - const url = accountTokenUrl(config.base_url); - console.log(`[AccountApi] Checking auth for ${url}`); - const response = await fetchOrThrow(url, { - method: "POST", - headers: withBasicAuth({}, user.username, user.password) - }); - const json = await response.json(); // May throw SyntaxError - if (!json.token) { - throw new Error(`Unexpected server response: Cannot find token`); + async login(user) { + const url = accountTokenUrl(config.base_url); + console.log(`[AccountApi] Checking auth for ${url}`); + const response = await fetchOrThrow(url, { + method: "POST", + headers: withBasicAuth({}, user.username, user.password), + }); + const json = await response.json(); // May throw SyntaxError + if (!json.token) { + throw new Error(`Unexpected server response: Cannot find token`); + } + return json.token; + } + + async logout() { + const url = accountTokenUrl(config.base_url); + console.log( + `[AccountApi] Logging out from ${url} using token ${session.token()}` + ); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()), + }); + } + + async create(username, password) { + const url = accountUrl(config.base_url); + const body = JSON.stringify({ + username: username, + password: password, + }); + console.log(`[AccountApi] Creating user account ${url}`); + await fetchOrThrow(url, { + method: "POST", + body: body, + }); + } + + async get() { + const url = accountUrl(config.base_url); + console.log(`[AccountApi] Fetching user account ${url}`); + const response = await fetchOrThrow(url, { + headers: maybeWithBearerAuth({}, session.token()), // GET /v1/account endpoint can be called by anonymous + }); + const account = await response.json(); // May throw SyntaxError + console.log(`[AccountApi] Account`, account); + if (this.listener) { + this.listener(account); + } + return account; + } + + async delete(password) { + const url = accountUrl(config.base_url); + console.log(`[AccountApi] Deleting user account ${url}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + password: password, + }), + }); + } + + async changePassword(currentPassword, newPassword) { + const url = accountPasswordUrl(config.base_url); + console.log(`[AccountApi] Changing account password ${url}`); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + password: currentPassword, + new_password: newPassword, + }), + }); + } + + async createToken(label, expires) { + const url = accountTokenUrl(config.base_url); + const body = { + label: label, + expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0, + }; + console.log(`[AccountApi] Creating user access token ${url}`); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify(body), + }); + } + + async updateToken(token, label, expires) { + const url = accountTokenUrl(config.base_url); + const body = { + token: token, + label: label, + }; + if (expires > 0) { + body.expires = Math.floor(Date.now() / 1000) + expires; + } + console.log(`[AccountApi] Creating user access token ${url}`); + await fetchOrThrow(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify(body), + }); + } + + async extendToken() { + const url = accountTokenUrl(config.base_url); + console.log(`[AccountApi] Extending user access token ${url}`); + await fetchOrThrow(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()), + }); + } + + async deleteToken(token) { + const url = accountTokenUrl(config.base_url); + console.log(`[AccountApi] Deleting user access token ${url}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({ "X-Token": token }, session.token()), + }); + } + + async updateSettings(payload) { + const url = accountSettingsUrl(config.base_url); + const body = JSON.stringify(payload); + console.log(`[AccountApi] Updating user account ${url}: ${body}`); + await fetchOrThrow(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()), + body: body, + }); + } + + async addSubscription(baseUrl, topic) { + const url = accountSubscriptionUrl(config.base_url); + const body = JSON.stringify({ + base_url: baseUrl, + topic: topic, + }); + console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); + const response = await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: body, + }); + const subscription = await response.json(); // May throw SyntaxError + console.log(`[AccountApi] Subscription`, subscription); + return subscription; + } + + async updateSubscription(baseUrl, topic, payload) { + const url = accountSubscriptionUrl(config.base_url); + const body = JSON.stringify({ + base_url: baseUrl, + topic: topic, + ...payload, + }); + console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); + const response = await fetchOrThrow(url, { + method: "PATCH", + headers: withBearerAuth({}, session.token()), + body: body, + }); + const subscription = await response.json(); // May throw SyntaxError + console.log(`[AccountApi] Subscription`, subscription); + return subscription; + } + + async deleteSubscription(baseUrl, topic) { + const url = accountSubscriptionUrl(config.base_url); + console.log(`[AccountApi] Removing user subscription ${url}`); + const headers = { + "X-BaseURL": baseUrl, + "X-Topic": topic, + }; + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth(headers, session.token()), + }); + } + + async upsertReservation(topic, everyone) { + const url = accountReservationUrl(config.base_url); + console.log( + `[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}` + ); + await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + topic: topic, + everyone: everyone, + }), + }); + } + + async deleteReservation(topic, deleteMessages) { + const url = accountReservationSingleUrl(config.base_url, topic); + console.log(`[AccountApi] Removing topic reservation ${url}`); + const headers = { + "X-Delete-Messages": deleteMessages ? "true" : "false", + }; + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth(headers, session.token()), + }); + } + + async billingTiers() { + if (this.tiers) { + return this.tiers; + } + const url = tiersUrl(config.base_url); + console.log(`[AccountApi] Fetching billing tiers`); + const response = await fetchOrThrow(url); // No auth needed! + this.tiers = await response.json(); // May throw SyntaxError + return this.tiers; + } + + async createBillingSubscription(tier, interval) { + console.log( + `[AccountApi] Creating billing subscription with ${tier} and interval ${interval}` + ); + return await this.upsertBillingSubscription("POST", tier, interval); + } + + async updateBillingSubscription(tier, interval) { + console.log( + `[AccountApi] Updating billing subscription with ${tier} and interval ${interval}` + ); + return await this.upsertBillingSubscription("PUT", tier, interval); + } + + async upsertBillingSubscription(method, tier, interval) { + const url = accountBillingSubscriptionUrl(config.base_url); + const response = await fetchOrThrow(url, { + method: method, + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + tier: tier, + interval: interval, + }), + }); + return await response.json(); // May throw SyntaxError + } + + async deleteBillingSubscription() { + const url = accountBillingSubscriptionUrl(config.base_url); + console.log(`[AccountApi] Cancelling billing subscription`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()), + }); + } + + async createBillingPortalSession() { + const url = accountBillingPortalUrl(config.base_url); + console.log(`[AccountApi] Creating billing portal session`); + const response = await fetchOrThrow(url, { + method: "POST", + headers: withBearerAuth({}, session.token()), + }); + return await response.json(); // May throw SyntaxError + } + + async verifyPhoneNumber(phoneNumber, channel) { + const url = accountPhoneVerifyUrl(config.base_url); + console.log(`[AccountApi] Sending phone verification ${url}`); + await fetchOrThrow(url, { + method: "PUT", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + number: phoneNumber, + channel: channel, + }), + }); + } + + async addPhoneNumber(phoneNumber, code) { + const url = accountPhoneUrl(config.base_url); + console.log( + `[AccountApi] Adding phone number with verification code ${url}` + ); + await fetchOrThrow(url, { + method: "PUT", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + number: phoneNumber, + code: code, + }), + }); + } + + async deletePhoneNumber(phoneNumber, code) { + const url = accountPhoneUrl(config.base_url); + console.log(`[AccountApi] Deleting phone number ${url}`); + await fetchOrThrow(url, { + method: "DELETE", + headers: withBearerAuth({}, session.token()), + body: JSON.stringify({ + number: phoneNumber, + }), + }); + } + + async sync() { + try { + if (!session.token()) { + return null; + } + console.log(`[AccountApi] Syncing account`); + const account = await this.get(); + if (account.language) { + await i18n.changeLanguage(account.language); + } + if (account.notification) { + if (account.notification.sound) { + await prefs.setSound(account.notification.sound); } - return json.token; - } - - async logout() { - const url = accountTokenUrl(config.base_url); - console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`); - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth({}, session.token()) - }); - } - - async create(username, password) { - const url = accountUrl(config.base_url); - const body = JSON.stringify({ - username: username, - password: password - }); - console.log(`[AccountApi] Creating user account ${url}`); - await fetchOrThrow(url, { - method: "POST", - body: body - }); - } - - async get() { - const url = accountUrl(config.base_url); - console.log(`[AccountApi] Fetching user account ${url}`); - const response = await fetchOrThrow(url, { - headers: maybeWithBearerAuth({}, session.token()) // GET /v1/account endpoint can be called by anonymous - }); - const account = await response.json(); // May throw SyntaxError - console.log(`[AccountApi] Account`, account); - if (this.listener) { - this.listener(account); + if (account.notification.delete_after) { + await prefs.setDeleteAfter(account.notification.delete_after); } - return account; - } - - async delete(password) { - const url = accountUrl(config.base_url); - console.log(`[AccountApi] Deleting user account ${url}`); - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - password: password - }) - }); - } - - async changePassword(currentPassword, newPassword) { - const url = accountPasswordUrl(config.base_url); - console.log(`[AccountApi] Changing account password ${url}`); - await fetchOrThrow(url, { - method: "POST", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - password: currentPassword, - new_password: newPassword - }) - }); - } - - async createToken(label, expires) { - const url = accountTokenUrl(config.base_url); - const body = { - label: label, - expires: (expires > 0) ? Math.floor(Date.now() / 1000) + expires : 0 - }; - console.log(`[AccountApi] Creating user access token ${url}`); - await fetchOrThrow(url, { - method: "POST", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify(body) - }); - } - - async updateToken(token, label, expires) { - const url = accountTokenUrl(config.base_url); - const body = { - token: token, - label: label - }; - if (expires > 0) { - body.expires = Math.floor(Date.now() / 1000) + expires; + if (account.notification.min_priority) { + await prefs.setMinPriority(account.notification.min_priority); } - console.log(`[AccountApi] Creating user access token ${url}`); - await fetchOrThrow(url, { - method: "PATCH", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify(body) - }); + } + if (account.subscriptions) { + await subscriptionManager.syncFromRemote( + account.subscriptions, + account.reservations + ); + } + return account; + } catch (e) { + console.log(`[AccountApi] Error fetching account`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } } + } - async extendToken() { - const url = accountTokenUrl(config.base_url); - console.log(`[AccountApi] Extending user access token ${url}`); - await fetchOrThrow(url, { - method: "PATCH", - headers: withBearerAuth({}, session.token()) - }); + startWorker() { + if (this.timer !== null) { + return; } + console.log(`[AccountApi] Starting worker`); + this.timer = setInterval(() => this.runWorker(), intervalMillis); + setTimeout(() => this.runWorker(), delayMillis); + } - async deleteToken(token) { - const url = accountTokenUrl(config.base_url); - console.log(`[AccountApi] Deleting user access token ${url}`); - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth({"X-Token": token}, session.token()) - }); + async runWorker() { + if (!session.token()) { + return; } - - async updateSettings(payload) { - const url = accountSettingsUrl(config.base_url); - const body = JSON.stringify(payload); - console.log(`[AccountApi] Updating user account ${url}: ${body}`); - await fetchOrThrow(url, { - method: "PATCH", - headers: withBearerAuth({}, session.token()), - body: body - }); - } - - async addSubscription(baseUrl, topic) { - const url = accountSubscriptionUrl(config.base_url); - const body = JSON.stringify({ - base_url: baseUrl, - topic: topic - }); - console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); - const response = await fetchOrThrow(url, { - method: "POST", - headers: withBearerAuth({}, session.token()), - body: body - }); - const subscription = await response.json(); // May throw SyntaxError - console.log(`[AccountApi] Subscription`, subscription); - return subscription; - } - - async updateSubscription(baseUrl, topic, payload) { - const url = accountSubscriptionUrl(config.base_url); - const body = JSON.stringify({ - base_url: baseUrl, - topic: topic, - ...payload - }); - console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); - const response = await fetchOrThrow(url, { - method: "PATCH", - headers: withBearerAuth({}, session.token()), - body: body - }); - const subscription = await response.json(); // May throw SyntaxError - console.log(`[AccountApi] Subscription`, subscription); - return subscription; - } - - async deleteSubscription(baseUrl, topic) { - const url = accountSubscriptionUrl(config.base_url); - console.log(`[AccountApi] Removing user subscription ${url}`); - const headers = { - "X-BaseURL": baseUrl, - "X-Topic": topic, - } - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth(headers, session.token()), - }); - } - - async upsertReservation(topic, everyone) { - const url = accountReservationUrl(config.base_url); - console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`); - await fetchOrThrow(url, { - method: "POST", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - topic: topic, - everyone: everyone - }) - }); - } - - async deleteReservation(topic, deleteMessages) { - const url = accountReservationSingleUrl(config.base_url, topic); - console.log(`[AccountApi] Removing topic reservation ${url}`); - const headers = { - "X-Delete-Messages": deleteMessages ? "true" : "false" - } - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth(headers, session.token()) - }); - } - - async billingTiers() { - if (this.tiers) { - return this.tiers; - } - const url = tiersUrl(config.base_url); - console.log(`[AccountApi] Fetching billing tiers`); - const response = await fetchOrThrow(url); // No auth needed! - this.tiers = await response.json(); // May throw SyntaxError - return this.tiers; - } - - async createBillingSubscription(tier, interval) { - console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`); - return await this.upsertBillingSubscription("POST", tier, interval) - } - - async updateBillingSubscription(tier, interval) { - console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`); - return await this.upsertBillingSubscription("PUT", tier, interval) - } - - async upsertBillingSubscription(method, tier, interval) { - const url = accountBillingSubscriptionUrl(config.base_url); - const response = await fetchOrThrow(url, { - method: method, - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - tier: tier, - interval: interval - }) - }); - return await response.json(); // May throw SyntaxError - } - - async deleteBillingSubscription() { - const url = accountBillingSubscriptionUrl(config.base_url); - console.log(`[AccountApi] Cancelling billing subscription`); - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth({}, session.token()) - }); - } - - async createBillingPortalSession() { - const url = accountBillingPortalUrl(config.base_url); - console.log(`[AccountApi] Creating billing portal session`); - const response = await fetchOrThrow(url, { - method: "POST", - headers: withBearerAuth({}, session.token()) - }); - return await response.json(); // May throw SyntaxError - } - - async verifyPhoneNumber(phoneNumber, channel) { - const url = accountPhoneVerifyUrl(config.base_url); - console.log(`[AccountApi] Sending phone verification ${url}`); - await fetchOrThrow(url, { - method: "PUT", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - number: phoneNumber, - channel: channel - }) - }); - } - - async addPhoneNumber(phoneNumber, code) { - const url = accountPhoneUrl(config.base_url); - console.log(`[AccountApi] Adding phone number with verification code ${url}`); - await fetchOrThrow(url, { - method: "PUT", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - number: phoneNumber, - code: code - }) - }); - } - - async deletePhoneNumber(phoneNumber, code) { - const url = accountPhoneUrl(config.base_url); - console.log(`[AccountApi] Deleting phone number ${url}`); - await fetchOrThrow(url, { - method: "DELETE", - headers: withBearerAuth({}, session.token()), - body: JSON.stringify({ - number: phoneNumber - }) - }); - } - - async sync() { - try { - if (!session.token()) { - return null; - } - console.log(`[AccountApi] Syncing account`); - const account = await this.get(); - if (account.language) { - await i18n.changeLanguage(account.language); - } - if (account.notification) { - if (account.notification.sound) { - await prefs.setSound(account.notification.sound); - } - if (account.notification.delete_after) { - await prefs.setDeleteAfter(account.notification.delete_after); - } - if (account.notification.min_priority) { - await prefs.setMinPriority(account.notification.min_priority); - } - } - if (account.subscriptions) { - await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations); - } - return account; - } catch (e) { - console.log(`[AccountApi] Error fetching account`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - } - } - - startWorker() { - if (this.timer !== null) { - return; - } - console.log(`[AccountApi] Starting worker`); - this.timer = setInterval(() => this.runWorker(), intervalMillis); - setTimeout(() => this.runWorker(), delayMillis); - } - - async runWorker() { - if (!session.token()) { - return; - } - console.log(`[AccountApi] Extending user access token`); - try { - await this.extendToken(); - } catch (e) { - console.log(`[AccountApi] Error extending user access token`, e); - } + console.log(`[AccountApi] Extending user access token`); + try { + await this.extendToken(); + } catch (e) { + console.log(`[AccountApi] Error extending user access token`, e); } + } } // Maps to user.Role in user/types.go export const Role = { - ADMIN: "admin", - USER: "user" + ADMIN: "admin", + USER: "user", }; // Maps to server.visitorLimitBasis in server/visitor.go export const LimitBasis = { - IP: "ip", - TIER: "tier" + IP: "ip", + TIER: "tier", }; // Maps to stripe.SubscriptionStatus export const SubscriptionStatus = { - ACTIVE: "active", - PAST_DUE: "past_due" + ACTIVE: "active", + PAST_DUE: "past_due", }; // Maps to stripe.PriceRecurringInterval export const SubscriptionInterval = { - MONTH: "month", - YEAR: "year" + MONTH: "month", + YEAR: "year", }; // Maps to user.Permission in user/types.go export const Permission = { - READ_WRITE: "read-write", - READ_ONLY: "read-only", - WRITE_ONLY: "write-only", - DENY_ALL: "deny-all" + READ_WRITE: "read-write", + READ_ONLY: "read-only", + WRITE_ONLY: "write-only", + DENY_ALL: "deny-all", }; const accountApi = new AccountApi(); diff --git a/web/src/app/Api.js b/web/src/app/Api.js index 59bd78b..345b0f2 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -1,118 +1,125 @@ import { - fetchLinesIterator, - maybeWithAuth, - topicShortUrl, - topicUrl, - topicUrlAuth, - topicUrlJsonPoll, - topicUrlJsonPollWithSince + fetchLinesIterator, + maybeWithAuth, + topicShortUrl, + topicUrl, + topicUrlAuth, + topicUrlJsonPoll, + topicUrlJsonPollWithSince, } from "./utils"; import userManager from "./UserManager"; -import {fetchOrThrow} from "./errors"; +import { fetchOrThrow } from "./errors"; class Api { - async poll(baseUrl, topic, since) { - const user = await userManager.get(baseUrl); - const shortUrl = topicShortUrl(baseUrl, topic); - const url = (since) - ? topicUrlJsonPollWithSince(baseUrl, topic, since) - : topicUrlJsonPoll(baseUrl, topic); - const messages = []; - const headers = maybeWithAuth({}, user); - console.log(`[Api] Polling ${url}`); - for await (let line of fetchLinesIterator(url, headers)) { - const message = JSON.parse(line); - if (message.id) { - console.log(`[Api, ${shortUrl}] Received message ${line}`); - messages.push(message); - } - } - return messages; + async poll(baseUrl, topic, since) { + const user = await userManager.get(baseUrl); + const shortUrl = topicShortUrl(baseUrl, topic); + const url = since + ? topicUrlJsonPollWithSince(baseUrl, topic, since) + : topicUrlJsonPoll(baseUrl, topic); + const messages = []; + const headers = maybeWithAuth({}, user); + console.log(`[Api] Polling ${url}`); + for await (let line of fetchLinesIterator(url, headers)) { + const message = JSON.parse(line); + if (message.id) { + console.log(`[Api, ${shortUrl}] Received message ${line}`); + messages.push(message); + } } + return messages; + } - async publish(baseUrl, topic, message, options) { - const user = await userManager.get(baseUrl); - console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`); - const headers = {}; - const body = { - topic: topic, - message: message, - ...options - }; - await fetchOrThrow(baseUrl, { - method: 'PUT', - body: JSON.stringify(body), - headers: maybeWithAuth(headers, user) - }); - } + async publish(baseUrl, topic, message, options) { + const user = await userManager.get(baseUrl); + console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`); + const headers = {}; + const body = { + topic: topic, + message: message, + ...options, + }; + await fetchOrThrow(baseUrl, { + method: "PUT", + body: JSON.stringify(body), + headers: maybeWithAuth(headers, user), + }); + } - /** - * Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request. - * Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used. - * - * Firefox XHR bug: - * Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error, - * so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the - * correct headers are clearly set. It's quite the odd behavior. - * - * There is an example, and the bug report here: - * - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755 - * - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345 - */ - publishXHR(url, body, headers, onProgress) { - console.log(`[Api] Publishing message to ${url}`); - const xhr = new XMLHttpRequest(); - const send = new Promise(function (resolve, reject) { - xhr.open("PUT", url); - if (body.type) { - xhr.overrideMimeType(body.type); + /** + * Publishes to a topic using XMLHttpRequest (XHR), and returns a Promise with the active request. + * Unfortunately, fetch() does not support a progress hook, which is why XHR has to be used. + * + * Firefox XHR bug: + * Firefox has a bug(?), which returns 0 and "" for all fields of the XHR response in the case of an error, + * so we cannot determine the exact error. It also sometimes complains about CORS violations, even when the + * correct headers are clearly set. It's quite the odd behavior. + * + * There is an example, and the bug report here: + * - https://bugzilla.mozilla.org/show_bug.cgi?id=1733755 + * - https://gist.github.com/binwiederhier/627f146d1959799be207ad8c17a8f345 + */ + publishXHR(url, body, headers, onProgress) { + console.log(`[Api] Publishing message to ${url}`); + const xhr = new XMLHttpRequest(); + const send = new Promise(function (resolve, reject) { + xhr.open("PUT", url); + if (body.type) { + xhr.overrideMimeType(body.type); + } + for (const [key, value] of Object.entries(headers)) { + xhr.setRequestHeader(key, value); + } + xhr.upload.addEventListener("progress", onProgress); + xhr.addEventListener("readystatechange", () => { + if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) { + console.log( + `[Api] Publish successful (HTTP ${xhr.status})`, + xhr.response + ); + resolve(xhr.response); + } else if (xhr.readyState === 4) { + // Firefox bug; see description above! + console.log( + `[Api] Publish failed (HTTP ${xhr.status})`, + xhr.responseText + ); + let errorText; + try { + const error = JSON.parse(xhr.responseText); + if (error.code && error.error) { + errorText = `Error ${error.code}: ${error.error}`; } - for (const [key, value] of Object.entries(headers)) { - xhr.setRequestHeader(key, value); - } - xhr.upload.addEventListener("progress", onProgress); - xhr.addEventListener('readystatechange', () => { - if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) { - console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response); - resolve(xhr.response); - } else if (xhr.readyState === 4) { - // Firefox bug; see description above! - console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText); - let errorText; - try { - const error = JSON.parse(xhr.responseText); - if (error.code && error.error) { - errorText = `Error ${error.code}: ${error.error}`; - } - } catch (e) { - // Nothing - } - xhr.abort(); - reject(errorText ?? "An error occurred"); - } - }) - xhr.send(body); - }); - send.abort = () => { - console.log(`[Api] Publish aborted by user`); - xhr.abort(); + } catch (e) { + // Nothing + } + xhr.abort(); + reject(errorText ?? "An error occurred"); } - return send; - } + }); + xhr.send(body); + }); + send.abort = () => { + console.log(`[Api] Publish aborted by user`); + xhr.abort(); + }; + return send; + } - async topicAuth(baseUrl, topic, user) { - const url = topicUrlAuth(baseUrl, topic); - console.log(`[Api] Checking auth for ${url}`); - const response = await fetch(url, { - headers: maybeWithAuth({}, user) - }); - if (response.status >= 200 && response.status <= 299) { - return true; - } else if (response.status === 401 || response.status === 403) { // See server/server.go - return false; - } - throw new Error(`Unexpected server response ${response.status}`); + async topicAuth(baseUrl, topic, user) { + const url = topicUrlAuth(baseUrl, topic); + console.log(`[Api] Checking auth for ${url}`); + const response = await fetch(url, { + headers: maybeWithAuth({}, user), + }); + if (response.status >= 200 && response.status <= 299) { + return true; + } else if (response.status === 401 || response.status === 403) { + // See server/server.go + return false; } + throw new Error(`Unexpected server response ${response.status}`); + } } const api = new Api(); diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index e86af78..5dfc41b 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -1,4 +1,10 @@ -import {basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils"; +import { + basicAuth, + bearerAuth, + encodeBase64Url, + topicShortUrl, + topicUrlWs, +} from "./utils"; const retryBackoffSeconds = [5, 10, 20, 30, 60, 120]; @@ -9,110 +15,142 @@ const retryBackoffSeconds = [5, 10, 20, 30, 60, 120]; * Incoming messages and state changes are forwarded via listeners. */ class Connection { - constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) { - this.connectionId = connectionId; - this.subscriptionId = subscriptionId; - this.baseUrl = baseUrl; - this.topic = topic; - this.user = user; - this.since = since; - this.shortUrl = topicShortUrl(baseUrl, topic); - this.onNotification = onNotification; - this.onStateChanged = onStateChanged; + constructor( + connectionId, + subscriptionId, + baseUrl, + topic, + user, + since, + onNotification, + onStateChanged + ) { + this.connectionId = connectionId; + this.subscriptionId = subscriptionId; + this.baseUrl = baseUrl; + this.topic = topic; + this.user = user; + this.since = since; + this.shortUrl = topicShortUrl(baseUrl, topic); + this.onNotification = onNotification; + this.onStateChanged = onStateChanged; + this.ws = null; + this.retryCount = 0; + this.retryTimeout = null; + } + + start() { + // Don't fetch old messages; we do that as a poll() when adding a subscription; + // we don't want to re-trigger the main view re-render potentially hundreds of times. + + const wsUrl = this.wsUrl(); + console.log( + `[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}` + ); + + this.ws = new WebSocket(wsUrl); + this.ws.onopen = (event) => { + console.log( + `[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, + event + ); + this.retryCount = 0; + this.onStateChanged(this.subscriptionId, ConnectionState.Connected); + }; + this.ws.onmessage = (event) => { + console.log( + `[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}` + ); + try { + const data = JSON.parse(event.data); + if (data.event === "open") { + return; + } + const relevantAndValid = + data.event === "message" && + "id" in data && + "time" in data && + "message" in data; + if (!relevantAndValid) { + console.log( + `[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.` + ); + return; + } + this.since = data.id; + this.onNotification(this.subscriptionId, data); + } catch (e) { + console.log( + `[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}` + ); + } + }; + this.ws.onclose = (event) => { + if (event.wasClean) { + console.log( + `[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}` + ); this.ws = null; - this.retryCount = 0; - this.retryTimeout = null; + } else { + const retrySeconds = + retryBackoffSeconds[ + Math.min(this.retryCount, retryBackoffSeconds.length - 1) + ]; + this.retryCount++; + console.log( + `[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds` + ); + this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000); + this.onStateChanged(this.subscriptionId, ConnectionState.Connecting); + } + }; + this.ws.onerror = (event) => { + console.log( + `[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, + event + ); + }; + } + + close() { + console.log( + `[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection` + ); + const socket = this.ws; + const retryTimeout = this.retryTimeout; + if (socket !== null) { + socket.close(); } - - start() { - // Don't fetch old messages; we do that as a poll() when adding a subscription; - // we don't want to re-trigger the main view re-render potentially hundreds of times. - - const wsUrl = this.wsUrl(); - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`); - - this.ws = new WebSocket(wsUrl); - this.ws.onopen = (event) => { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event); - this.retryCount = 0; - this.onStateChanged(this.subscriptionId, ConnectionState.Connected); - } - this.ws.onmessage = (event) => { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`); - try { - const data = JSON.parse(event.data); - if (data.event === 'open') { - return; - } - const relevantAndValid = - data.event === 'message' && - 'id' in data && - 'time' in data && - 'message' in data; - if (!relevantAndValid) { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`); - return; - } - this.since = data.id; - this.onNotification(this.subscriptionId, data); - } catch (e) { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`); - } - }; - this.ws.onclose = (event) => { - if (event.wasClean) { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`); - this.ws = null; - } else { - const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length-1)]; - this.retryCount++; - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`); - this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000); - this.onStateChanged(this.subscriptionId, ConnectionState.Connecting); - } - }; - this.ws.onerror = (event) => { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event); - }; + if (retryTimeout !== null) { + clearTimeout(retryTimeout); } + this.retryTimeout = null; + this.ws = null; + } - close() { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`); - const socket = this.ws; - const retryTimeout = this.retryTimeout; - if (socket !== null) { - socket.close(); - } - if (retryTimeout !== null) { - clearTimeout(retryTimeout); - } - this.retryTimeout = null; - this.ws = null; + wsUrl() { + const params = []; + if (this.since) { + params.push(`since=${this.since}`); } + if (this.user) { + params.push(`auth=${this.authParam()}`); + } + const wsUrl = topicUrlWs(this.baseUrl, this.topic); + return params.length === 0 ? wsUrl : `${wsUrl}?${params.join("&")}`; + } - wsUrl() { - const params = []; - if (this.since) { - params.push(`since=${this.since}`); - } - if (this.user) { - params.push(`auth=${this.authParam()}`); - } - const wsUrl = topicUrlWs(this.baseUrl, this.topic); - return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`; - } - - authParam() { - if (this.user.password) { - return encodeBase64Url(basicAuth(this.user.username, this.user.password)); - } - return encodeBase64Url(bearerAuth(this.user.token)); + authParam() { + if (this.user.password) { + return encodeBase64Url(basicAuth(this.user.username, this.user.password)); } + return encodeBase64Url(bearerAuth(this.user.token)); + } } export class ConnectionState { - static Connected = "connected"; - static Connecting = "connecting"; + static Connected = "connected"; + static Connecting = "connecting"; } export default Connection; diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js index 1e805eb..ced32d5 100644 --- a/web/src/app/ConnectionManager.js +++ b/web/src/app/ConnectionManager.js @@ -1,5 +1,5 @@ import Connection from "./Connection"; -import {hashCode} from "./utils"; +import { hashCode } from "./utils"; /** * The connection manager keeps track of active connections (WebSocket connections, see Connection). @@ -8,110 +8,130 @@ import {hashCode} from "./utils"; * as required. This is done pretty much exactly the same way as in the Android app. */ class ConnectionManager { - constructor() { - this.connections = new Map(); // ConnectionId -> Connection (hash, see below) - this.stateListener = null; // Fired when connection state changes - this.messageListener = null; // Fired when new notifications arrive + constructor() { + this.connections = new Map(); // ConnectionId -> Connection (hash, see below) + this.stateListener = null; // Fired when connection state changes + this.messageListener = null; // Fired when new notifications arrive + } + + registerStateListener(listener) { + this.stateListener = listener; + } + + resetStateListener() { + this.stateListener = null; + } + + registerMessageListener(listener) { + this.messageListener = listener; + } + + resetMessageListener() { + this.messageListener = null; + } + + /** + * This function figures out which websocket connections should be running by comparing the + * current state of the world (connections) with the target state (targetIds). + * + * It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify + * connections. If any of them change, the connection is closed/replaced. + */ + async refresh(subscriptions, users) { + if (!subscriptions || !users) { + return; } + console.log(`[ConnectionManager] Refreshing connections`); + const subscriptionsWithUsersAndConnectionId = await Promise.all( + subscriptions.map(async (s) => { + const [user] = users.filter((u) => u.baseUrl === s.baseUrl); + const connectionId = await makeConnectionId(s, user); + return { ...s, user, connectionId }; + }) + ); + const targetIds = subscriptionsWithUsersAndConnectionId.map( + (s) => s.connectionId + ); + const deletedIds = Array.from(this.connections.keys()).filter( + (id) => !targetIds.includes(id) + ); - registerStateListener(listener) { - this.stateListener = listener; + // Create and add new connections + subscriptionsWithUsersAndConnectionId.forEach((subscription) => { + const subscriptionId = subscription.id; + const connectionId = subscription.connectionId; + const added = !this.connections.get(connectionId); + if (added) { + const baseUrl = subscription.baseUrl; + const topic = subscription.topic; + const user = subscription.user; + const since = subscription.last; + const connection = new Connection( + connectionId, + subscriptionId, + baseUrl, + topic, + user, + since, + (subscriptionId, notification) => + this.notificationReceived(subscriptionId, notification), + (subscriptionId, state) => this.stateChanged(subscriptionId, state) + ); + this.connections.set(connectionId, connection); + console.log( + `[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${ + user ? user.username : "anonymous" + })` + ); + connection.start(); + } + }); + + // Delete old connections + deletedIds.forEach((id) => { + console.log(`[ConnectionManager] Closing connection ${id}`); + const connection = this.connections.get(id); + this.connections.delete(id); + connection.close(); + }); + } + + stateChanged(subscriptionId, state) { + if (this.stateListener) { + try { + this.stateListener(subscriptionId, state); + } catch (e) { + console.error( + `[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, + e + ); + } } + } - resetStateListener() { - this.stateListener = null; - } - - registerMessageListener(listener) { - this.messageListener = listener; - } - - resetMessageListener() { - this.messageListener = null; - } - - /** - * This function figures out which websocket connections should be running by comparing the - * current state of the world (connections) with the target state (targetIds). - * - * It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify - * connections. If any of them change, the connection is closed/replaced. - */ - async refresh(subscriptions, users) { - if (!subscriptions || !users) { - return; - } - console.log(`[ConnectionManager] Refreshing connections`); - const subscriptionsWithUsersAndConnectionId = await Promise.all(subscriptions - .map(async s => { - const [user] = users.filter(u => u.baseUrl === s.baseUrl); - const connectionId = await makeConnectionId(s, user); - return {...s, user, connectionId}; - })); - const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId); - const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id)); - - // Create and add new connections - subscriptionsWithUsersAndConnectionId.forEach(subscription => { - const subscriptionId = subscription.id; - const connectionId = subscription.connectionId; - const added = !this.connections.get(connectionId) - if (added) { - const baseUrl = subscription.baseUrl; - const topic = subscription.topic; - const user = subscription.user; - const since = subscription.last; - const connection = new Connection( - connectionId, - subscriptionId, - baseUrl, - topic, - user, - since, - (subscriptionId, notification) => this.notificationReceived(subscriptionId, notification), - (subscriptionId, state) => this.stateChanged(subscriptionId, state) - ); - this.connections.set(connectionId, connection); - console.log(`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`); - connection.start(); - } - }); - - // Delete old connections - deletedIds.forEach(id => { - console.log(`[ConnectionManager] Closing connection ${id}`); - const connection = this.connections.get(id); - this.connections.delete(id); - connection.close(); - }); - } - - stateChanged(subscriptionId, state) { - if (this.stateListener) { - try { - this.stateListener(subscriptionId, state); - } catch (e) { - console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e); - } - } - } - - notificationReceived(subscriptionId, notification) { - if (this.messageListener) { - try { - this.messageListener(subscriptionId, notification); - } catch (e) { - console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e); - } - } + notificationReceived(subscriptionId, notification) { + if (this.messageListener) { + try { + this.messageListener(subscriptionId, notification); + } catch (e) { + console.error( + `[ConnectionManager] Error handling notification for ${subscriptionId}`, + e + ); + } } + } } const makeConnectionId = async (subscription, user) => { - return (user) - ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) - : hashCode(`${subscription.id}`); -} + return user + ? hashCode( + `${subscription.id}|${user.username}|${user.password ?? ""}|${ + user.token ?? "" + }` + ) + : hashCode(`${subscription.id}`); +}; const connectionManager = new ConnectionManager(); export default connectionManager; diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index 613340c..e4396d2 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -1,4 +1,11 @@ -import {formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl} from "./utils"; +import { + formatMessage, + formatTitleWithDefault, + openUrl, + playSound, + topicDisplayName, + topicShortUrl, +} from "./utils"; import prefs from "./Prefs"; import subscriptionManager from "./SubscriptionManager"; import logo from "../img/ntfy.png"; @@ -8,89 +15,93 @@ import logo from "../img/ntfy.png"; * support this; most importantly, all iOS browsers do not support window.Notification. */ class Notifier { - async notify(subscriptionId, notification, onClickFallback) { - if (!this.supported()) { - return; - } - const subscription = await subscriptionManager.get(subscriptionId); - const shouldNotify = await this.shouldNotify(subscription, notification); - if (!shouldNotify) { - return; - } - const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); - const displayName = topicDisplayName(subscription); - const message = formatMessage(notification); - const title = formatTitleWithDefault(notification, displayName); + async notify(subscriptionId, notification, onClickFallback) { + if (!this.supported()) { + return; + } + const subscription = await subscriptionManager.get(subscriptionId); + const shouldNotify = await this.shouldNotify(subscription, notification); + if (!shouldNotify) { + return; + } + const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); + const displayName = topicDisplayName(subscription); + const message = formatMessage(notification); + const title = formatTitleWithDefault(notification, displayName); - // Show notification - console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`); - const n = new Notification(title, { - body: message, - icon: logo - }); - if (notification.click) { - n.onclick = (e) => openUrl(notification.click); - } else { - n.onclick = () => onClickFallback(subscription); - } - - // Play sound - const sound = await prefs.sound(); - if (sound && sound !== "none") { - try { - await playSound(sound); - } catch (e) { - console.log(`[Notifier, ${shortUrl}] Error playing audio`, e); - } - } + // Show notification + console.log( + `[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}` + ); + const n = new Notification(title, { + body: message, + icon: logo, + }); + if (notification.click) { + n.onclick = (e) => openUrl(notification.click); + } else { + n.onclick = () => onClickFallback(subscription); } - granted() { - return this.supported() && Notification.permission === 'granted'; + // Play sound + const sound = await prefs.sound(); + if (sound && sound !== "none") { + try { + await playSound(sound); + } catch (e) { + console.log(`[Notifier, ${shortUrl}] Error playing audio`, e); + } } + } - maybeRequestPermission(cb) { - if (!this.supported()) { - cb(false); - return; - } - if (!this.granted()) { - Notification.requestPermission().then((permission) => { - const granted = permission === 'granted'; - cb(granted); - }); - } - } + granted() { + return this.supported() && Notification.permission === "granted"; + } - async shouldNotify(subscription, notification) { - if (subscription.mutedUntil === 1) { - return false; - } - const priority = (notification.priority) ? notification.priority : 3; - const minPriority = await prefs.minPriority(); - if (priority < minPriority) { - return false; - } - return true; + maybeRequestPermission(cb) { + if (!this.supported()) { + cb(false); + return; } + if (!this.granted()) { + Notification.requestPermission().then((permission) => { + const granted = permission === "granted"; + cb(granted); + }); + } + } - supported() { - return this.browserSupported() && this.contextSupported(); + async shouldNotify(subscription, notification) { + if (subscription.mutedUntil === 1) { + return false; } + const priority = notification.priority ? notification.priority : 3; + const minPriority = await prefs.minPriority(); + if (priority < minPriority) { + return false; + } + return true; + } - browserSupported() { - return 'Notification' in window; - } + supported() { + return this.browserSupported() && this.contextSupported(); + } - /** - * Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API - * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification - */ - contextSupported() { - return location.protocol === 'https:' - || location.hostname.match('^127.') - || location.hostname === 'localhost'; - } + browserSupported() { + return "Notification" in window; + } + + /** + * Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API + * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification + */ + contextSupported() { + return ( + location.protocol === "https:" || + location.hostname.match("^127.") || + location.hostname === "localhost" + ); + } } const notifier = new Notifier(); diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index a7eed03..d2bf696 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -5,54 +5,60 @@ const delayMillis = 2000; // 2 seconds const intervalMillis = 300000; // 5 minutes class Poller { - constructor() { - this.timer = null; - } + constructor() { + this.timer = null; + } - startWorker() { - if (this.timer !== null) { - return; - } - console.log(`[Poller] Starting worker`); - this.timer = setInterval(() => this.pollAll(), intervalMillis); - setTimeout(() => this.pollAll(), delayMillis); + startWorker() { + if (this.timer !== null) { + return; } + console.log(`[Poller] Starting worker`); + this.timer = setInterval(() => this.pollAll(), intervalMillis); + setTimeout(() => this.pollAll(), delayMillis); + } - async pollAll() { - console.log(`[Poller] Polling all subscriptions`); - const subscriptions = await subscriptionManager.all(); - for (const s of subscriptions) { - try { - await this.poll(s); - } catch (e) { - console.log(`[Poller] Error polling ${s.id}`, e); - } - } + async pollAll() { + console.log(`[Poller] Polling all subscriptions`); + const subscriptions = await subscriptionManager.all(); + for (const s of subscriptions) { + try { + await this.poll(s); + } catch (e) { + console.log(`[Poller] Error polling ${s.id}`, e); + } } + } - async poll(subscription) { - console.log(`[Poller] Polling ${subscription.id}`); + async poll(subscription) { + console.log(`[Poller] Polling ${subscription.id}`); - const since = subscription.last; - const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); - if (!notifications || notifications.length === 0) { - console.log(`[Poller] No new notifications found for ${subscription.id}`); - return; - } - console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); - await subscriptionManager.addNotifications(subscription.id, notifications); + const since = subscription.last; + const notifications = await api.poll( + subscription.baseUrl, + subscription.topic, + since + ); + if (!notifications || notifications.length === 0) { + console.log(`[Poller] No new notifications found for ${subscription.id}`); + return; } + console.log( + `[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}` + ); + await subscriptionManager.addNotifications(subscription.id, notifications); + } - pollInBackground(subscription) { - const fn = async () => { - try { - await this.poll(subscription); - } catch (e) { - console.error(`[App] Error polling subscription ${subscription.id}`, e); - } - }; - setTimeout(() => fn(), 0); - } + pollInBackground(subscription) { + const fn = async () => { + try { + await this.poll(subscription); + } catch (e) { + console.error(`[App] Error polling subscription ${subscription.id}`, e); + } + }; + setTimeout(() => fn(), 0); + } } const poller = new Poller(); diff --git a/web/src/app/Prefs.js b/web/src/app/Prefs.js index b444c6f..8adc508 100644 --- a/web/src/app/Prefs.js +++ b/web/src/app/Prefs.js @@ -1,32 +1,32 @@ import db from "./db"; class Prefs { - async setSound(sound) { - db.prefs.put({key: 'sound', value: sound.toString()}); - } + async setSound(sound) { + db.prefs.put({ key: "sound", value: sound.toString() }); + } - async sound() { - const sound = await db.prefs.get('sound'); - return (sound) ? sound.value : "ding"; - } + async sound() { + const sound = await db.prefs.get("sound"); + return sound ? sound.value : "ding"; + } - async setMinPriority(minPriority) { - db.prefs.put({key: 'minPriority', value: minPriority.toString()}); - } + async setMinPriority(minPriority) { + db.prefs.put({ key: "minPriority", value: minPriority.toString() }); + } - async minPriority() { - const minPriority = await db.prefs.get('minPriority'); - return (minPriority) ? Number(minPriority.value) : 1; - } + async minPriority() { + const minPriority = await db.prefs.get("minPriority"); + return minPriority ? Number(minPriority.value) : 1; + } - async setDeleteAfter(deleteAfter) { - db.prefs.put({key:'deleteAfter', value: deleteAfter.toString()}); - } + async setDeleteAfter(deleteAfter) { + db.prefs.put({ key: "deleteAfter", value: deleteAfter.toString() }); + } - async deleteAfter() { - const deleteAfter = await db.prefs.get('deleteAfter'); - return (deleteAfter) ? Number(deleteAfter.value) : 604800; // Default is one week - } + async deleteAfter() { + const deleteAfter = await db.prefs.get("deleteAfter"); + return deleteAfter ? Number(deleteAfter.value) : 604800; // Default is one week + } } const prefs = new Prefs(); diff --git a/web/src/app/Pruner.js b/web/src/app/Pruner.js index 4594805..84853b6 100644 --- a/web/src/app/Pruner.js +++ b/web/src/app/Pruner.js @@ -5,33 +5,36 @@ const delayMillis = 25000; // 25 seconds const intervalMillis = 1800000; // 30 minutes class Pruner { - constructor() { - this.timer = null; - } + constructor() { + this.timer = null; + } - startWorker() { - if (this.timer !== null) { - return; - } - console.log(`[Pruner] Starting worker`); - this.timer = setInterval(() => this.prune(), intervalMillis); - setTimeout(() => this.prune(), delayMillis); + startWorker() { + if (this.timer !== null) { + return; } + console.log(`[Pruner] Starting worker`); + this.timer = setInterval(() => this.prune(), intervalMillis); + setTimeout(() => this.prune(), delayMillis); + } - async prune() { - const deleteAfterSeconds = await prefs.deleteAfter(); - const pruneThresholdTimestamp = Math.round(Date.now()/1000) - deleteAfterSeconds; - if (deleteAfterSeconds === 0) { - console.log(`[Pruner] Pruning is disabled. Skipping.`); - return; - } - console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`); - try { - await subscriptionManager.pruneNotifications(pruneThresholdTimestamp); - } catch (e) { - console.log(`[Pruner] Error pruning old subscriptions`, e); - } + async prune() { + const deleteAfterSeconds = await prefs.deleteAfter(); + const pruneThresholdTimestamp = + Math.round(Date.now() / 1000) - deleteAfterSeconds; + if (deleteAfterSeconds === 0) { + console.log(`[Pruner] Pruning is disabled. Skipping.`); + return; } + console.log( + `[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})` + ); + try { + await subscriptionManager.pruneNotifications(pruneThresholdTimestamp); + } catch (e) { + console.log(`[Pruner] Error pruning old subscriptions`, e); + } + } } const pruner = new Pruner(); diff --git a/web/src/app/Session.js b/web/src/app/Session.js index 45f4842..0b47f93 100644 --- a/web/src/app/Session.js +++ b/web/src/app/Session.js @@ -1,30 +1,30 @@ class Session { - store(username, token) { - localStorage.setItem("user", username); - localStorage.setItem("token", token); - } + store(username, token) { + localStorage.setItem("user", username); + localStorage.setItem("token", token); + } - reset() { - localStorage.removeItem("user"); - localStorage.removeItem("token"); - } + reset() { + localStorage.removeItem("user"); + localStorage.removeItem("token"); + } - resetAndRedirect(url) { - this.reset(); - window.location.href = url; - } + resetAndRedirect(url) { + this.reset(); + window.location.href = url; + } - exists() { - return this.username() && this.token(); - } + exists() { + return this.username() && this.token(); + } - username() { - return localStorage.getItem("user"); - } + username() { + return localStorage.getItem("user"); + } - token() { - return localStorage.getItem("token"); - } + token() { + return localStorage.getItem("token"); + } } const session = new Session(); diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index cdfe50e..25d0830 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -1,192 +1,193 @@ import db from "./db"; -import {topicUrl} from "./utils"; +import { topicUrl } from "./utils"; class SubscriptionManager { - /** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */ - async all() { - const subscriptions = await db.subscriptions.toArray(); - await Promise.all(subscriptions.map(async s => { - s.new = await db.notifications - .where({ subscriptionId: s.id, new: 1 }) - .count(); - })); - return subscriptions; + /** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */ + async all() { + const subscriptions = await db.subscriptions.toArray(); + await Promise.all( + subscriptions.map(async (s) => { + s.new = await db.notifications + .where({ subscriptionId: s.id, new: 1 }) + .count(); + }) + ); + return subscriptions; + } + + async get(subscriptionId) { + return await db.subscriptions.get(subscriptionId); + } + + async add(baseUrl, topic, internal) { + const id = topicUrl(baseUrl, topic); + const existingSubscription = await this.get(id); + if (existingSubscription) { + return existingSubscription; + } + const subscription = { + id: topicUrl(baseUrl, topic), + baseUrl: baseUrl, + topic: topic, + mutedUntil: 0, + last: null, + internal: internal || false, + }; + await db.subscriptions.put(subscription); + return subscription; + } + + async syncFromRemote(remoteSubscriptions, remoteReservations) { + console.log( + `[SubscriptionManager] Syncing subscriptions from remote`, + remoteSubscriptions + ); + + // Add remote subscriptions + let remoteIds = []; // = topicUrl(baseUrl, topic) + for (let i = 0; i < remoteSubscriptions.length; i++) { + const remote = remoteSubscriptions[i]; + const local = await this.add(remote.base_url, remote.topic, false); + const reservation = + remoteReservations?.find( + (r) => remote.base_url === config.base_url && remote.topic === r.topic + ) || null; + await this.update(local.id, { + displayName: remote.display_name, // May be undefined + reservation: reservation, // May be null! + }); + remoteIds.push(local.id); } - async get(subscriptionId) { - return await db.subscriptions.get(subscriptionId) + // Remove local subscriptions that do not exist remotely + const localSubscriptions = await db.subscriptions.toArray(); + for (let i = 0; i < localSubscriptions.length; i++) { + const local = localSubscriptions[i]; + const remoteExists = remoteIds.includes(local.id); + if (!local.internal && !remoteExists) { + await this.remove(local.id); + } } + } - async add(baseUrl, topic, internal) { - const id = topicUrl(baseUrl, topic); - const existingSubscription = await this.get(id); - if (existingSubscription) { - return existingSubscription; - } - const subscription = { - id: topicUrl(baseUrl, topic), - baseUrl: baseUrl, - topic: topic, - mutedUntil: 0, - last: null, - internal: internal || false - }; - await db.subscriptions.put(subscription); - return subscription; + async updateState(subscriptionId, state) { + db.subscriptions.update(subscriptionId, { state: state }); + } + + async remove(subscriptionId) { + await db.subscriptions.delete(subscriptionId); + await db.notifications.where({ subscriptionId: subscriptionId }).delete(); + } + + async first() { + return db.subscriptions.toCollection().first(); // May be undefined + } + + async getNotifications(subscriptionId) { + // This is quite awkward, but it is the recommended approach as per the Dexie docs. + // It's actually fine, because the reading and filtering is quite fast. The rendering is what's + // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach + + return db.notifications + .orderBy("time") // Sort by time first + .filter((n) => n.subscriptionId === subscriptionId) + .reverse() + .toArray(); + } + + async getAllNotifications() { + return db.notifications + .orderBy("time") // Efficient, see docs + .reverse() + .toArray(); + } + + /** Adds notification, or returns false if it already exists */ + async addNotification(subscriptionId, notification) { + const exists = await db.notifications.get(notification.id); + if (exists) { + return false; } - - async syncFromRemote(remoteSubscriptions, remoteReservations) { - console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions); - - // Add remote subscriptions - let remoteIds = []; // = topicUrl(baseUrl, topic) - for (let i = 0; i < remoteSubscriptions.length; i++) { - const remote = remoteSubscriptions[i]; - const local = await this.add(remote.base_url, remote.topic, false); - const reservation = remoteReservations?.find(r => remote.base_url === config.base_url && remote.topic === r.topic) || null; - await this.update(local.id, { - displayName: remote.display_name, // May be undefined - reservation: reservation // May be null! - }); - remoteIds.push(local.id); - } - - // Remove local subscriptions that do not exist remotely - const localSubscriptions = await db.subscriptions.toArray(); - for (let i = 0; i < localSubscriptions.length; i++) { - const local = localSubscriptions[i]; - const remoteExists = remoteIds.includes(local.id); - if (!local.internal && !remoteExists) { - await this.remove(local.id); - } - } + try { + notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation + await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab + await db.subscriptions.update(subscriptionId, { + last: notification.id, + }); + } catch (e) { + console.error(`[SubscriptionManager] Error adding notification`, e); } + return true; + } - async updateState(subscriptionId, state) { - db.subscriptions.update(subscriptionId, { state: state }); + /** Adds/replaces notifications, will not throw if they exist */ + async addNotifications(subscriptionId, notifications) { + const notificationsWithSubscriptionId = notifications.map( + (notification) => ({ ...notification, subscriptionId }) + ); + const lastNotificationId = notifications.at(-1).id; + await db.notifications.bulkPut(notificationsWithSubscriptionId); + await db.subscriptions.update(subscriptionId, { + last: lastNotificationId, + }); + } + + async updateNotification(notification) { + const exists = await db.notifications.get(notification.id); + if (!exists) { + return false; } - - async remove(subscriptionId) { - await db.subscriptions.delete(subscriptionId); - await db.notifications - .where({subscriptionId: subscriptionId}) - .delete(); + try { + await db.notifications.put({ ...notification }); + } catch (e) { + console.error(`[SubscriptionManager] Error updating notification`, e); } + return true; + } - async first() { - return db.subscriptions.toCollection().first(); // May be undefined - } + async deleteNotification(notificationId) { + await db.notifications.delete(notificationId); + } - async getNotifications(subscriptionId) { - // This is quite awkward, but it is the recommended approach as per the Dexie docs. - // It's actually fine, because the reading and filtering is quite fast. The rendering is what's - // killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach + async deleteNotifications(subscriptionId) { + await db.notifications.where({ subscriptionId: subscriptionId }).delete(); + } - return db.notifications - .orderBy("time") // Sort by time first - .filter(n => n.subscriptionId === subscriptionId) - .reverse() - .toArray(); - } + async markNotificationRead(notificationId) { + await db.notifications.where({ id: notificationId }).modify({ new: 0 }); + } - async getAllNotifications() { - return db.notifications - .orderBy("time") // Efficient, see docs - .reverse() - .toArray(); - } + async markNotificationsRead(subscriptionId) { + await db.notifications + .where({ subscriptionId: subscriptionId, new: 1 }) + .modify({ new: 0 }); + } - /** Adds notification, or returns false if it already exists */ - async addNotification(subscriptionId, notification) { - const exists = await db.notifications.get(notification.id); - if (exists) { - return false; - } - try { - notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation - await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab - await db.subscriptions.update(subscriptionId, { - last: notification.id - }); - } catch (e) { - console.error(`[SubscriptionManager] Error adding notification`, e); - } - return true; - } + async setMutedUntil(subscriptionId, mutedUntil) { + await db.subscriptions.update(subscriptionId, { + mutedUntil: mutedUntil, + }); + } - /** Adds/replaces notifications, will not throw if they exist */ - async addNotifications(subscriptionId, notifications) { - const notificationsWithSubscriptionId = notifications - .map(notification => ({ ...notification, subscriptionId })); - const lastNotificationId = notifications.at(-1).id; - await db.notifications.bulkPut(notificationsWithSubscriptionId); - await db.subscriptions.update(subscriptionId, { - last: lastNotificationId - }); - } + async setDisplayName(subscriptionId, displayName) { + await db.subscriptions.update(subscriptionId, { + displayName: displayName, + }); + } - async updateNotification(notification) { - const exists = await db.notifications.get(notification.id); - if (!exists) { - return false; - } - try { - await db.notifications.put({ ...notification }); - } catch (e) { - console.error(`[SubscriptionManager] Error updating notification`, e); - } - return true; - } + async setReservation(subscriptionId, reservation) { + await db.subscriptions.update(subscriptionId, { + reservation: reservation, + }); + } - async deleteNotification(notificationId) { - await db.notifications.delete(notificationId); - } + async update(subscriptionId, params) { + await db.subscriptions.update(subscriptionId, params); + } - async deleteNotifications(subscriptionId) { - await db.notifications - .where({subscriptionId: subscriptionId}) - .delete(); - } - - async markNotificationRead(notificationId) { - await db.notifications - .where({id: notificationId}) - .modify({new: 0}); - } - - async markNotificationsRead(subscriptionId) { - await db.notifications - .where({subscriptionId: subscriptionId, new: 1}) - .modify({new: 0}); - } - - async setMutedUntil(subscriptionId, mutedUntil) { - await db.subscriptions.update(subscriptionId, { - mutedUntil: mutedUntil - }); - } - - async setDisplayName(subscriptionId, displayName) { - await db.subscriptions.update(subscriptionId, { - displayName: displayName - }); - } - - async setReservation(subscriptionId, reservation) { - await db.subscriptions.update(subscriptionId, { - reservation: reservation - }); - } - - async update(subscriptionId, params) { - await db.subscriptions.update(subscriptionId, params); - } - - async pruneNotifications(thresholdTimestamp) { - await db.notifications - .where("time").below(thresholdTimestamp) - .delete(); - } + async pruneNotifications(thresholdTimestamp) { + await db.notifications.where("time").below(thresholdTimestamp).delete(); + } } const subscriptionManager = new SubscriptionManager(); diff --git a/web/src/app/UserManager.js b/web/src/app/UserManager.js index 1e54eb0..2cdd544 100644 --- a/web/src/app/UserManager.js +++ b/web/src/app/UserManager.js @@ -2,45 +2,45 @@ import db from "./db"; import session from "./Session"; class UserManager { - async all() { - const users = await db.users.toArray(); - if (session.exists()) { - users.unshift(this.localUser()); - } - return users; + async all() { + const users = await db.users.toArray(); + if (session.exists()) { + users.unshift(this.localUser()); } + return users; + } - async get(baseUrl) { - if (session.exists() && baseUrl === config.base_url) { - return this.localUser(); - } - return db.users.get(baseUrl); + async get(baseUrl) { + if (session.exists() && baseUrl === config.base_url) { + return this.localUser(); } + return db.users.get(baseUrl); + } - async save(user) { - if (session.exists() && user.baseUrl === config.base_url) { - return; - } - await db.users.put(user); + async save(user) { + if (session.exists() && user.baseUrl === config.base_url) { + return; } + await db.users.put(user); + } - async delete(baseUrl) { - if (session.exists() && baseUrl === config.base_url) { - return; - } - await db.users.delete(baseUrl); + async delete(baseUrl) { + if (session.exists() && baseUrl === config.base_url) { + return; } + await db.users.delete(baseUrl); + } - localUser() { - if (!session.exists()) { - return null; - } - return { - baseUrl: config.base_url, - username: session.username(), - token: session.token() // Not "password"! - }; + localUser() { + if (!session.exists()) { + return null; } + return { + baseUrl: config.base_url, + username: session.username(), + token: session.token(), // Not "password"! + }; + } } const userManager = new UserManager(); diff --git a/web/src/app/config.js b/web/src/app/config.js index bdec53e..15225f5 100644 --- a/web/src/app/config.js +++ b/web/src/app/config.js @@ -3,7 +3,7 @@ const config = window.config; // The backend returns an empty base_url for the config struct, // so the frontend (hey, that's us!) can use the current location. if (!config.base_url || config.base_url === "") { - config.base_url = window.location.origin; + config.base_url = window.location.origin; } export default config; diff --git a/web/src/app/db.js b/web/src/app/db.js index 564ee1c..0e1a5e7 100644 --- a/web/src/app/db.js +++ b/web/src/app/db.js @@ -1,4 +1,4 @@ -import Dexie from 'dexie'; +import Dexie from "dexie"; import session from "./Session"; // Uses Dexie.js @@ -8,14 +8,14 @@ import session from "./Session"; // - As per docs, we only declare the indexable columns, not all columns // The IndexedDB database name is based on the logged-in user -const dbName = (session.username()) ? `ntfy-${session.username()}` : "ntfy"; +const dbName = session.username() ? `ntfy-${session.username()}` : "ntfy"; const db = new Dexie(dbName); db.version(1).stores({ - subscriptions: '&id,baseUrl', - notifications: '&id,subscriptionId,time,new,[subscriptionId+new]', // compound key for query performance - users: '&baseUrl,username', - prefs: '&key' + subscriptions: "&id,baseUrl", + notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance + users: "&baseUrl,username", + prefs: "&key", }); export default db; diff --git a/web/src/app/emojis.js b/web/src/app/emojis.js index f6dac7b..b7912c3 100644 --- a/web/src/app/emojis.js +++ b/web/src/app/emojis.js @@ -1,3 +1,14500 @@ // This file is generated by scripts/emoji-convert.sh to reduce the size // Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json -export const rawEmojis = [{"emoji":"😀","aliases":["grinning"],"tags":["smile","happy"],"category":"Smileys & Emotion","description":"grinning face","unicode_version":"6.1"},{"emoji":"😃","aliases":["smiley"],"tags":["happy","joy","haha"],"category":"Smileys & Emotion","description":"grinning face with big eyes","unicode_version":"6.0"},{"emoji":"😄","aliases":["smile"],"tags":["happy","joy","laugh","pleased"],"category":"Smileys & Emotion","description":"grinning face with smiling eyes","unicode_version":"6.0"},{"emoji":"😁","aliases":["grin"],"tags":[],"category":"Smileys & Emotion","description":"beaming face with smiling eyes","unicode_version":"6.0"},{"emoji":"😆","aliases":["laughing","satisfied"],"tags":["happy","haha"],"category":"Smileys & Emotion","description":"grinning squinting face","unicode_version":"6.0"},{"emoji":"😅","aliases":["sweat_smile"],"tags":["hot"],"category":"Smileys & Emotion","description":"grinning face with sweat","unicode_version":"6.0"},{"emoji":"🤣","aliases":["rofl"],"tags":["lol","laughing"],"category":"Smileys & Emotion","description":"rolling on the floor laughing","unicode_version":"9.0"},{"emoji":"😂","aliases":["joy"],"tags":["tears"],"category":"Smileys & Emotion","description":"face with tears of joy","unicode_version":"6.0"},{"emoji":"🙂","aliases":["slightly_smiling_face"],"tags":[],"category":"Smileys & Emotion","description":"slightly smiling face","unicode_version":"7.0"},{"emoji":"🙃","aliases":["upside_down_face"],"tags":[],"category":"Smileys & Emotion","description":"upside-down face","unicode_version":"8.0"},{"emoji":"😉","aliases":["wink"],"tags":["flirt"],"category":"Smileys & Emotion","description":"winking face","unicode_version":"6.0"},{"emoji":"😊","aliases":["blush"],"tags":["proud"],"category":"Smileys & Emotion","description":"smiling face with smiling eyes","unicode_version":"6.0"},{"emoji":"😇","aliases":["innocent"],"tags":["angel"],"category":"Smileys & Emotion","description":"smiling face with halo","unicode_version":"6.0"},{"emoji":"🥰","aliases":["smiling_face_with_three_hearts"],"tags":["love"],"category":"Smileys & Emotion","description":"smiling face with hearts","unicode_version":"11.0"},{"emoji":"😍","aliases":["heart_eyes"],"tags":["love","crush"],"category":"Smileys & Emotion","description":"smiling face with heart-eyes","unicode_version":"6.0"},{"emoji":"🤩","aliases":["star_struck"],"tags":["eyes"],"category":"Smileys & Emotion","description":"star-struck","unicode_version":"11.0"},{"emoji":"😘","aliases":["kissing_heart"],"tags":["flirt"],"category":"Smileys & Emotion","description":"face blowing a kiss","unicode_version":"6.0"},{"emoji":"😗","aliases":["kissing"],"tags":[],"category":"Smileys & Emotion","description":"kissing face","unicode_version":"6.1"},{"emoji":"☺️","aliases":["relaxed"],"tags":["blush","pleased"],"category":"Smileys & Emotion","description":"smiling face","unicode_version":""},{"emoji":"😚","aliases":["kissing_closed_eyes"],"tags":[],"category":"Smileys & Emotion","description":"kissing face with closed eyes","unicode_version":"6.0"},{"emoji":"😙","aliases":["kissing_smiling_eyes"],"tags":[],"category":"Smileys & Emotion","description":"kissing face with smiling eyes","unicode_version":"6.1"},{"emoji":"🥲","aliases":["smiling_face_with_tear"],"tags":[],"category":"Smileys & Emotion","description":"smiling face with tear","unicode_version":"13.0"},{"emoji":"😋","aliases":["yum"],"tags":["tongue","lick"],"category":"Smileys & Emotion","description":"face savoring food","unicode_version":"6.0"},{"emoji":"😛","aliases":["stuck_out_tongue"],"tags":[],"category":"Smileys & Emotion","description":"face with tongue","unicode_version":"6.1"},{"emoji":"😜","aliases":["stuck_out_tongue_winking_eye"],"tags":["prank","silly"],"category":"Smileys & Emotion","description":"winking face with tongue","unicode_version":"6.0"},{"emoji":"🤪","aliases":["zany_face"],"tags":["goofy","wacky"],"category":"Smileys & Emotion","description":"zany face","unicode_version":"11.0"},{"emoji":"😝","aliases":["stuck_out_tongue_closed_eyes"],"tags":["prank"],"category":"Smileys & Emotion","description":"squinting face with tongue","unicode_version":"6.0"},{"emoji":"🤑","aliases":["money_mouth_face"],"tags":["rich"],"category":"Smileys & Emotion","description":"money-mouth face","unicode_version":"8.0"},{"emoji":"🤗","aliases":["hugs"],"tags":[],"category":"Smileys & Emotion","description":"hugging face","unicode_version":"8.0"},{"emoji":"🤭","aliases":["hand_over_mouth"],"tags":["quiet","whoops"],"category":"Smileys & Emotion","description":"face with hand over mouth","unicode_version":"11.0"},{"emoji":"🤫","aliases":["shushing_face"],"tags":["silence","quiet"],"category":"Smileys & Emotion","description":"shushing face","unicode_version":"11.0"},{"emoji":"🤔","aliases":["thinking"],"tags":[],"category":"Smileys & Emotion","description":"thinking face","unicode_version":"8.0"},{"emoji":"🤐","aliases":["zipper_mouth_face"],"tags":["silence","hush"],"category":"Smileys & Emotion","description":"zipper-mouth face","unicode_version":"8.0"},{"emoji":"🤨","aliases":["raised_eyebrow"],"tags":["suspicious"],"category":"Smileys & Emotion","description":"face with raised eyebrow","unicode_version":"11.0"},{"emoji":"😐","aliases":["neutral_face"],"tags":["meh"],"category":"Smileys & Emotion","description":"neutral face","unicode_version":"6.0"},{"emoji":"😑","aliases":["expressionless"],"tags":[],"category":"Smileys & Emotion","description":"expressionless face","unicode_version":"6.1"},{"emoji":"😶","aliases":["no_mouth"],"tags":["mute","silence"],"category":"Smileys & Emotion","description":"face without mouth","unicode_version":"6.0"},{"emoji":"😶‍🌫️","aliases":["face_in_clouds"],"tags":[],"category":"Smileys & Emotion","description":"face in clouds","unicode_version":"13.1"},{"emoji":"😏","aliases":["smirk"],"tags":["smug"],"category":"Smileys & Emotion","description":"smirking face","unicode_version":"6.0"},{"emoji":"😒","aliases":["unamused"],"tags":["meh"],"category":"Smileys & Emotion","description":"unamused face","unicode_version":"6.0"},{"emoji":"🙄","aliases":["roll_eyes"],"tags":[],"category":"Smileys & Emotion","description":"face with rolling eyes","unicode_version":"8.0"},{"emoji":"😬","aliases":["grimacing"],"tags":[],"category":"Smileys & Emotion","description":"grimacing face","unicode_version":"6.1"},{"emoji":"😮‍💨","aliases":["face_exhaling"],"tags":[],"category":"Smileys & Emotion","description":"face exhaling","unicode_version":"13.1"},{"emoji":"🤥","aliases":["lying_face"],"tags":["liar"],"category":"Smileys & Emotion","description":"lying face","unicode_version":"9.0"},{"emoji":"😌","aliases":["relieved"],"tags":["whew"],"category":"Smileys & Emotion","description":"relieved face","unicode_version":"6.0"},{"emoji":"😔","aliases":["pensive"],"tags":[],"category":"Smileys & Emotion","description":"pensive face","unicode_version":"6.0"},{"emoji":"😪","aliases":["sleepy"],"tags":["tired"],"category":"Smileys & Emotion","description":"sleepy face","unicode_version":"6.0"},{"emoji":"🤤","aliases":["drooling_face"],"tags":[],"category":"Smileys & Emotion","description":"drooling face","unicode_version":"9.0"},{"emoji":"😴","aliases":["sleeping"],"tags":["zzz"],"category":"Smileys & Emotion","description":"sleeping face","unicode_version":"6.1"},{"emoji":"😷","aliases":["mask"],"tags":["sick","ill"],"category":"Smileys & Emotion","description":"face with medical mask","unicode_version":"6.0"},{"emoji":"🤒","aliases":["face_with_thermometer"],"tags":["sick"],"category":"Smileys & Emotion","description":"face with thermometer","unicode_version":"8.0"},{"emoji":"🤕","aliases":["face_with_head_bandage"],"tags":["hurt"],"category":"Smileys & Emotion","description":"face with head-bandage","unicode_version":"8.0"},{"emoji":"🤢","aliases":["nauseated_face"],"tags":["sick","barf","disgusted"],"category":"Smileys & Emotion","description":"nauseated face","unicode_version":"9.0"},{"emoji":"🤮","aliases":["vomiting_face"],"tags":["barf","sick"],"category":"Smileys & Emotion","description":"face vomiting","unicode_version":"11.0"},{"emoji":"🤧","aliases":["sneezing_face"],"tags":["achoo","sick"],"category":"Smileys & Emotion","description":"sneezing face","unicode_version":"9.0"},{"emoji":"🥵","aliases":["hot_face"],"tags":["heat","sweating"],"category":"Smileys & Emotion","description":"hot face","unicode_version":"11.0"},{"emoji":"🥶","aliases":["cold_face"],"tags":["freezing","ice"],"category":"Smileys & Emotion","description":"cold face","unicode_version":"11.0"},{"emoji":"🥴","aliases":["woozy_face"],"tags":["groggy"],"category":"Smileys & Emotion","description":"woozy face","unicode_version":"11.0"},{"emoji":"😵","aliases":["dizzy_face"],"tags":[],"category":"Smileys & Emotion","description":"knocked-out face","unicode_version":"6.0"},{"emoji":"😵‍💫","aliases":["face_with_spiral_eyes"],"tags":[],"category":"Smileys & Emotion","description":"face with spiral eyes","unicode_version":"13.1"},{"emoji":"🤯","aliases":["exploding_head"],"tags":["mind","blown"],"category":"Smileys & Emotion","description":"exploding head","unicode_version":"11.0"},{"emoji":"🤠","aliases":["cowboy_hat_face"],"tags":[],"category":"Smileys & Emotion","description":"cowboy hat face","unicode_version":"9.0"},{"emoji":"🥳","aliases":["partying_face"],"tags":["celebration","birthday"],"category":"Smileys & Emotion","description":"partying face","unicode_version":"11.0"},{"emoji":"🥸","aliases":["disguised_face"],"tags":[],"category":"Smileys & Emotion","description":"disguised face","unicode_version":"13.0"},{"emoji":"😎","aliases":["sunglasses"],"tags":["cool"],"category":"Smileys & Emotion","description":"smiling face with sunglasses","unicode_version":"6.0"},{"emoji":"🤓","aliases":["nerd_face"],"tags":["geek","glasses"],"category":"Smileys & Emotion","description":"nerd face","unicode_version":"8.0"},{"emoji":"🧐","aliases":["monocle_face"],"tags":[],"category":"Smileys & Emotion","description":"face with monocle","unicode_version":"11.0"},{"emoji":"😕","aliases":["confused"],"tags":[],"category":"Smileys & Emotion","description":"confused face","unicode_version":"6.1"},{"emoji":"😟","aliases":["worried"],"tags":["nervous"],"category":"Smileys & Emotion","description":"worried face","unicode_version":"6.1"},{"emoji":"🙁","aliases":["slightly_frowning_face"],"tags":[],"category":"Smileys & Emotion","description":"slightly frowning face","unicode_version":"7.0"},{"emoji":"☹️","aliases":["frowning_face"],"tags":[],"category":"Smileys & Emotion","description":"frowning face","unicode_version":""},{"emoji":"😮","aliases":["open_mouth"],"tags":["surprise","impressed","wow"],"category":"Smileys & Emotion","description":"face with open mouth","unicode_version":"6.1"},{"emoji":"😯","aliases":["hushed"],"tags":["silence","speechless"],"category":"Smileys & Emotion","description":"hushed face","unicode_version":"6.1"},{"emoji":"😲","aliases":["astonished"],"tags":["amazed","gasp"],"category":"Smileys & Emotion","description":"astonished face","unicode_version":"6.0"},{"emoji":"😳","aliases":["flushed"],"tags":[],"category":"Smileys & Emotion","description":"flushed face","unicode_version":"6.0"},{"emoji":"🥺","aliases":["pleading_face"],"tags":["puppy","eyes"],"category":"Smileys & Emotion","description":"pleading face","unicode_version":"11.0"},{"emoji":"😦","aliases":["frowning"],"tags":[],"category":"Smileys & Emotion","description":"frowning face with open mouth","unicode_version":"6.1"},{"emoji":"😧","aliases":["anguished"],"tags":["stunned"],"category":"Smileys & Emotion","description":"anguished face","unicode_version":"6.1"},{"emoji":"😨","aliases":["fearful"],"tags":["scared","shocked","oops"],"category":"Smileys & Emotion","description":"fearful face","unicode_version":"6.0"},{"emoji":"😰","aliases":["cold_sweat"],"tags":["nervous"],"category":"Smileys & Emotion","description":"anxious face with sweat","unicode_version":"6.0"},{"emoji":"😥","aliases":["disappointed_relieved"],"tags":["phew","sweat","nervous"],"category":"Smileys & Emotion","description":"sad but relieved face","unicode_version":"6.0"},{"emoji":"😢","aliases":["cry"],"tags":["sad","tear"],"category":"Smileys & Emotion","description":"crying face","unicode_version":"6.0"},{"emoji":"😭","aliases":["sob"],"tags":["sad","cry","bawling"],"category":"Smileys & Emotion","description":"loudly crying face","unicode_version":"6.0"},{"emoji":"😱","aliases":["scream"],"tags":["horror","shocked"],"category":"Smileys & Emotion","description":"face screaming in fear","unicode_version":"6.0"},{"emoji":"😖","aliases":["confounded"],"tags":[],"category":"Smileys & Emotion","description":"confounded face","unicode_version":"6.0"},{"emoji":"😣","aliases":["persevere"],"tags":["struggling"],"category":"Smileys & Emotion","description":"persevering face","unicode_version":"6.0"},{"emoji":"😞","aliases":["disappointed"],"tags":["sad"],"category":"Smileys & Emotion","description":"disappointed face","unicode_version":"6.0"},{"emoji":"😓","aliases":["sweat"],"tags":[],"category":"Smileys & Emotion","description":"downcast face with sweat","unicode_version":"6.0"},{"emoji":"😩","aliases":["weary"],"tags":["tired"],"category":"Smileys & Emotion","description":"weary face","unicode_version":"6.0"},{"emoji":"😫","aliases":["tired_face"],"tags":["upset","whine"],"category":"Smileys & Emotion","description":"tired face","unicode_version":"6.0"},{"emoji":"🥱","aliases":["yawning_face"],"tags":[],"category":"Smileys & Emotion","description":"yawning face","unicode_version":"12.0"},{"emoji":"😤","aliases":["triumph"],"tags":["smug"],"category":"Smileys & Emotion","description":"face with steam from nose","unicode_version":"6.0"},{"emoji":"😡","aliases":["rage","pout"],"tags":["angry"],"category":"Smileys & Emotion","description":"pouting face","unicode_version":"6.0"},{"emoji":"😠","aliases":["angry"],"tags":["mad","annoyed"],"category":"Smileys & Emotion","description":"angry face","unicode_version":"6.0"},{"emoji":"🤬","aliases":["cursing_face"],"tags":["foul"],"category":"Smileys & Emotion","description":"face with symbols on mouth","unicode_version":"11.0"},{"emoji":"😈","aliases":["smiling_imp"],"tags":["devil","evil","horns"],"category":"Smileys & Emotion","description":"smiling face with horns","unicode_version":"6.0"},{"emoji":"👿","aliases":["imp"],"tags":["angry","devil","evil","horns"],"category":"Smileys & Emotion","description":"angry face with horns","unicode_version":"6.0"},{"emoji":"💀","aliases":["skull"],"tags":["dead","danger","poison"],"category":"Smileys & Emotion","description":"skull","unicode_version":"6.0"},{"emoji":"☠️","aliases":["skull_and_crossbones"],"tags":["danger","pirate"],"category":"Smileys & Emotion","description":"skull and crossbones","unicode_version":""},{"emoji":"💩","aliases":["hankey","poop","shit"],"tags":["crap"],"category":"Smileys & Emotion","description":"pile of poo","unicode_version":"6.0"},{"emoji":"🤡","aliases":["clown_face"],"tags":[],"category":"Smileys & Emotion","description":"clown face","unicode_version":"9.0"},{"emoji":"👹","aliases":["japanese_ogre"],"tags":["monster"],"category":"Smileys & Emotion","description":"ogre","unicode_version":"6.0"},{"emoji":"👺","aliases":["japanese_goblin"],"tags":[],"category":"Smileys & Emotion","description":"goblin","unicode_version":"6.0"},{"emoji":"👻","aliases":["ghost"],"tags":["halloween"],"category":"Smileys & Emotion","description":"ghost","unicode_version":"6.0"},{"emoji":"👽","aliases":["alien"],"tags":["ufo"],"category":"Smileys & Emotion","description":"alien","unicode_version":"6.0"},{"emoji":"👾","aliases":["space_invader"],"tags":["game","retro"],"category":"Smileys & Emotion","description":"alien monster","unicode_version":"6.0"},{"emoji":"🤖","aliases":["robot"],"tags":[],"category":"Smileys & Emotion","description":"robot","unicode_version":"8.0"},{"emoji":"😺","aliases":["smiley_cat"],"tags":[],"category":"Smileys & Emotion","description":"grinning cat","unicode_version":"6.0"},{"emoji":"😸","aliases":["smile_cat"],"tags":[],"category":"Smileys & Emotion","description":"grinning cat with smiling eyes","unicode_version":"6.0"},{"emoji":"😹","aliases":["joy_cat"],"tags":[],"category":"Smileys & Emotion","description":"cat with tears of joy","unicode_version":"6.0"},{"emoji":"😻","aliases":["heart_eyes_cat"],"tags":[],"category":"Smileys & Emotion","description":"smiling cat with heart-eyes","unicode_version":"6.0"},{"emoji":"😼","aliases":["smirk_cat"],"tags":[],"category":"Smileys & Emotion","description":"cat with wry smile","unicode_version":"6.0"},{"emoji":"😽","aliases":["kissing_cat"],"tags":[],"category":"Smileys & Emotion","description":"kissing cat","unicode_version":"6.0"},{"emoji":"🙀","aliases":["scream_cat"],"tags":["horror"],"category":"Smileys & Emotion","description":"weary cat","unicode_version":"6.0"},{"emoji":"😿","aliases":["crying_cat_face"],"tags":["sad","tear"],"category":"Smileys & Emotion","description":"crying cat","unicode_version":"6.0"},{"emoji":"😾","aliases":["pouting_cat"],"tags":[],"category":"Smileys & Emotion","description":"pouting cat","unicode_version":"6.0"},{"emoji":"🙈","aliases":["see_no_evil"],"tags":["monkey","blind","ignore"],"category":"Smileys & Emotion","description":"see-no-evil monkey","unicode_version":"6.0"},{"emoji":"🙉","aliases":["hear_no_evil"],"tags":["monkey","deaf"],"category":"Smileys & Emotion","description":"hear-no-evil monkey","unicode_version":"6.0"},{"emoji":"🙊","aliases":["speak_no_evil"],"tags":["monkey","mute","hush"],"category":"Smileys & Emotion","description":"speak-no-evil monkey","unicode_version":"6.0"},{"emoji":"💋","aliases":["kiss"],"tags":["lipstick"],"category":"Smileys & Emotion","description":"kiss mark","unicode_version":"6.0"},{"emoji":"💌","aliases":["love_letter"],"tags":["email","envelope"],"category":"Smileys & Emotion","description":"love letter","unicode_version":"6.0"},{"emoji":"💘","aliases":["cupid"],"tags":["love","heart"],"category":"Smileys & Emotion","description":"heart with arrow","unicode_version":"6.0"},{"emoji":"💝","aliases":["gift_heart"],"tags":["chocolates"],"category":"Smileys & Emotion","description":"heart with ribbon","unicode_version":"6.0"},{"emoji":"💖","aliases":["sparkling_heart"],"tags":[],"category":"Smileys & Emotion","description":"sparkling heart","unicode_version":"6.0"},{"emoji":"💗","aliases":["heartpulse"],"tags":[],"category":"Smileys & Emotion","description":"growing heart","unicode_version":"6.0"},{"emoji":"💓","aliases":["heartbeat"],"tags":[],"category":"Smileys & Emotion","description":"beating heart","unicode_version":"6.0"},{"emoji":"💞","aliases":["revolving_hearts"],"tags":[],"category":"Smileys & Emotion","description":"revolving hearts","unicode_version":"6.0"},{"emoji":"💕","aliases":["two_hearts"],"tags":[],"category":"Smileys & Emotion","description":"two hearts","unicode_version":"6.0"},{"emoji":"💟","aliases":["heart_decoration"],"tags":[],"category":"Smileys & Emotion","description":"heart decoration","unicode_version":"6.0"},{"emoji":"❣️","aliases":["heavy_heart_exclamation"],"tags":[],"category":"Smileys & Emotion","description":"heart exclamation","unicode_version":""},{"emoji":"💔","aliases":["broken_heart"],"tags":[],"category":"Smileys & Emotion","description":"broken heart","unicode_version":"6.0"},{"emoji":"❤️‍🔥","aliases":["heart_on_fire"],"tags":[],"category":"Smileys & Emotion","description":"heart on fire","unicode_version":"13.1"},{"emoji":"❤️‍🩹","aliases":["mending_heart"],"tags":[],"category":"Smileys & Emotion","description":"mending heart","unicode_version":"13.1"},{"emoji":"❤️","aliases":["heart"],"tags":["love"],"category":"Smileys & Emotion","description":"red heart","unicode_version":""},{"emoji":"🧡","aliases":["orange_heart"],"tags":[],"category":"Smileys & Emotion","description":"orange heart","unicode_version":"11.0"},{"emoji":"💛","aliases":["yellow_heart"],"tags":[],"category":"Smileys & Emotion","description":"yellow heart","unicode_version":"6.0"},{"emoji":"💚","aliases":["green_heart"],"tags":[],"category":"Smileys & Emotion","description":"green heart","unicode_version":"6.0"},{"emoji":"💙","aliases":["blue_heart"],"tags":[],"category":"Smileys & Emotion","description":"blue heart","unicode_version":"6.0"},{"emoji":"💜","aliases":["purple_heart"],"tags":[],"category":"Smileys & Emotion","description":"purple heart","unicode_version":"6.0"},{"emoji":"🤎","aliases":["brown_heart"],"tags":[],"category":"Smileys & Emotion","description":"brown heart","unicode_version":"12.0"},{"emoji":"🖤","aliases":["black_heart"],"tags":[],"category":"Smileys & Emotion","description":"black heart","unicode_version":"9.0"},{"emoji":"🤍","aliases":["white_heart"],"tags":[],"category":"Smileys & Emotion","description":"white heart","unicode_version":"12.0"},{"emoji":"💯","aliases":["100"],"tags":["score","perfect"],"category":"Smileys & Emotion","description":"hundred points","unicode_version":"6.0"},{"emoji":"💢","aliases":["anger"],"tags":["angry"],"category":"Smileys & Emotion","description":"anger symbol","unicode_version":"6.0"},{"emoji":"💥","aliases":["boom","collision"],"tags":["explode"],"category":"Smileys & Emotion","description":"collision","unicode_version":"6.0"},{"emoji":"💫","aliases":["dizzy"],"tags":["star"],"category":"Smileys & Emotion","description":"dizzy","unicode_version":"6.0"},{"emoji":"💦","aliases":["sweat_drops"],"tags":["water","workout"],"category":"Smileys & Emotion","description":"sweat droplets","unicode_version":"6.0"},{"emoji":"💨","aliases":["dash"],"tags":["wind","blow","fast"],"category":"Smileys & Emotion","description":"dashing away","unicode_version":"6.0"},{"emoji":"🕳️","aliases":["hole"],"tags":[],"category":"Smileys & Emotion","description":"hole","unicode_version":"7.0"},{"emoji":"💣","aliases":["bomb"],"tags":["boom"],"category":"Smileys & Emotion","description":"bomb","unicode_version":"6.0"},{"emoji":"💬","aliases":["speech_balloon"],"tags":["comment"],"category":"Smileys & Emotion","description":"speech balloon","unicode_version":"6.0"},{"emoji":"👁️‍🗨️","aliases":["eye_speech_bubble"],"tags":[],"category":"Smileys & Emotion","description":"eye in speech bubble","unicode_version":"11.0"},{"emoji":"🗨️","aliases":["left_speech_bubble"],"tags":[],"category":"Smileys & Emotion","description":"left speech bubble","unicode_version":"11.0"},{"emoji":"🗯️","aliases":["right_anger_bubble"],"tags":[],"category":"Smileys & Emotion","description":"right anger bubble","unicode_version":"7.0"},{"emoji":"💭","aliases":["thought_balloon"],"tags":["thinking"],"category":"Smileys & Emotion","description":"thought balloon","unicode_version":"6.0"},{"emoji":"💤","aliases":["zzz"],"tags":["sleeping"],"category":"Smileys & Emotion","description":"zzz","unicode_version":"6.0"},{"emoji":"👋","aliases":["wave"],"tags":["goodbye"],"category":"People & Body","description":"waving hand","unicode_version":"6.0"},{"emoji":"🤚","aliases":["raised_back_of_hand"],"tags":[],"category":"People & Body","description":"raised back of hand","unicode_version":"9.0"},{"emoji":"🖐️","aliases":["raised_hand_with_fingers_splayed"],"tags":[],"category":"People & Body","description":"hand with fingers splayed","unicode_version":"7.0"},{"emoji":"✋","aliases":["hand","raised_hand"],"tags":["highfive","stop"],"category":"People & Body","description":"raised hand","unicode_version":"6.0"},{"emoji":"🖖","aliases":["vulcan_salute"],"tags":["prosper","spock"],"category":"People & Body","description":"vulcan salute","unicode_version":"7.0"},{"emoji":"👌","aliases":["ok_hand"],"tags":[],"category":"People & Body","description":"OK hand","unicode_version":"6.0"},{"emoji":"🤌","aliases":["pinched_fingers"],"tags":[],"category":"People & Body","description":"pinched fingers","unicode_version":"13.0"},{"emoji":"🤏","aliases":["pinching_hand"],"tags":[],"category":"People & Body","description":"pinching hand","unicode_version":"12.0"},{"emoji":"✌️","aliases":["v"],"tags":["victory","peace"],"category":"People & Body","description":"victory hand","unicode_version":""},{"emoji":"🤞","aliases":["crossed_fingers"],"tags":["luck","hopeful"],"category":"People & Body","description":"crossed fingers","unicode_version":"9.0"},{"emoji":"🤟","aliases":["love_you_gesture"],"tags":[],"category":"People & Body","description":"love-you gesture","unicode_version":"11.0"},{"emoji":"🤘","aliases":["metal"],"tags":[],"category":"People & Body","description":"sign of the horns","unicode_version":"8.0"},{"emoji":"🤙","aliases":["call_me_hand"],"tags":[],"category":"People & Body","description":"call me hand","unicode_version":"9.0"},{"emoji":"👈","aliases":["point_left"],"tags":[],"category":"People & Body","description":"backhand index pointing left","unicode_version":"6.0"},{"emoji":"👉","aliases":["point_right"],"tags":[],"category":"People & Body","description":"backhand index pointing right","unicode_version":"6.0"},{"emoji":"👆","aliases":["point_up_2"],"tags":[],"category":"People & Body","description":"backhand index pointing up","unicode_version":"6.0"},{"emoji":"🖕","aliases":["middle_finger","fu"],"tags":[],"category":"People & Body","description":"middle finger","unicode_version":"7.0"},{"emoji":"👇","aliases":["point_down"],"tags":[],"category":"People & Body","description":"backhand index pointing down","unicode_version":"6.0"},{"emoji":"☝️","aliases":["point_up"],"tags":[],"category":"People & Body","description":"index pointing up","unicode_version":""},{"emoji":"👍","aliases":["+1","thumbsup"],"tags":["approve","ok"],"category":"People & Body","description":"thumbs up","unicode_version":"6.0"},{"emoji":"👎","aliases":["-1","thumbsdown"],"tags":["disapprove","bury"],"category":"People & Body","description":"thumbs down","unicode_version":"6.0"},{"emoji":"✊","aliases":["fist_raised","fist"],"tags":["power"],"category":"People & Body","description":"raised fist","unicode_version":"6.0"},{"emoji":"👊","aliases":["fist_oncoming","facepunch","punch"],"tags":["attack"],"category":"People & Body","description":"oncoming fist","unicode_version":"6.0"},{"emoji":"🤛","aliases":["fist_left"],"tags":[],"category":"People & Body","description":"left-facing fist","unicode_version":"9.0"},{"emoji":"🤜","aliases":["fist_right"],"tags":[],"category":"People & Body","description":"right-facing fist","unicode_version":"9.0"},{"emoji":"👏","aliases":["clap"],"tags":["praise","applause"],"category":"People & Body","description":"clapping hands","unicode_version":"6.0"},{"emoji":"🙌","aliases":["raised_hands"],"tags":["hooray"],"category":"People & Body","description":"raising hands","unicode_version":"6.0"},{"emoji":"👐","aliases":["open_hands"],"tags":[],"category":"People & Body","description":"open hands","unicode_version":"6.0"},{"emoji":"🤲","aliases":["palms_up_together"],"tags":[],"category":"People & Body","description":"palms up together","unicode_version":"11.0"},{"emoji":"🤝","aliases":["handshake"],"tags":["deal"],"category":"People & Body","description":"handshake","unicode_version":"9.0"},{"emoji":"🙏","aliases":["pray"],"tags":["please","hope","wish"],"category":"People & Body","description":"folded hands","unicode_version":"6.0"},{"emoji":"✍️","aliases":["writing_hand"],"tags":[],"category":"People & Body","description":"writing hand","unicode_version":""},{"emoji":"💅","aliases":["nail_care"],"tags":["beauty","manicure"],"category":"People & Body","description":"nail polish","unicode_version":"6.0"},{"emoji":"🤳","aliases":["selfie"],"tags":[],"category":"People & Body","description":"selfie","unicode_version":"9.0"},{"emoji":"💪","aliases":["muscle"],"tags":["flex","bicep","strong","workout"],"category":"People & Body","description":"flexed biceps","unicode_version":"6.0"},{"emoji":"🦾","aliases":["mechanical_arm"],"tags":[],"category":"People & Body","description":"mechanical arm","unicode_version":"12.0"},{"emoji":"🦿","aliases":["mechanical_leg"],"tags":[],"category":"People & Body","description":"mechanical leg","unicode_version":"12.0"},{"emoji":"🦵","aliases":["leg"],"tags":[],"category":"People & Body","description":"leg","unicode_version":"11.0"},{"emoji":"🦶","aliases":["foot"],"tags":[],"category":"People & Body","description":"foot","unicode_version":"11.0"},{"emoji":"👂","aliases":["ear"],"tags":["hear","sound","listen"],"category":"People & Body","description":"ear","unicode_version":"6.0"},{"emoji":"🦻","aliases":["ear_with_hearing_aid"],"tags":[],"category":"People & Body","description":"ear with hearing aid","unicode_version":"12.0"},{"emoji":"👃","aliases":["nose"],"tags":["smell"],"category":"People & Body","description":"nose","unicode_version":"6.0"},{"emoji":"🧠","aliases":["brain"],"tags":[],"category":"People & Body","description":"brain","unicode_version":"11.0"},{"emoji":"🫀","aliases":["anatomical_heart"],"tags":[],"category":"People & Body","description":"anatomical heart","unicode_version":"13.0"},{"emoji":"🫁","aliases":["lungs"],"tags":[],"category":"People & Body","description":"lungs","unicode_version":"13.0"},{"emoji":"🦷","aliases":["tooth"],"tags":[],"category":"People & Body","description":"tooth","unicode_version":"11.0"},{"emoji":"🦴","aliases":["bone"],"tags":[],"category":"People & Body","description":"bone","unicode_version":"11.0"},{"emoji":"👀","aliases":["eyes"],"tags":["look","see","watch"],"category":"People & Body","description":"eyes","unicode_version":"6.0"},{"emoji":"👁️","aliases":["eye"],"tags":[],"category":"People & Body","description":"eye","unicode_version":"7.0"},{"emoji":"👅","aliases":["tongue"],"tags":["taste"],"category":"People & Body","description":"tongue","unicode_version":"6.0"},{"emoji":"👄","aliases":["lips"],"tags":["kiss"],"category":"People & Body","description":"mouth","unicode_version":"6.0"},{"emoji":"👶","aliases":["baby"],"tags":["child","newborn"],"category":"People & Body","description":"baby","unicode_version":"6.0"},{"emoji":"🧒","aliases":["child"],"tags":[],"category":"People & Body","description":"child","unicode_version":"11.0"},{"emoji":"👦","aliases":["boy"],"tags":["child"],"category":"People & Body","description":"boy","unicode_version":"6.0"},{"emoji":"👧","aliases":["girl"],"tags":["child"],"category":"People & Body","description":"girl","unicode_version":"6.0"},{"emoji":"🧑","aliases":["adult"],"tags":[],"category":"People & Body","description":"person","unicode_version":"11.0"},{"emoji":"👱","aliases":["blond_haired_person"],"tags":[],"category":"People & Body","description":"person: blond hair","unicode_version":"6.0"},{"emoji":"👨","aliases":["man"],"tags":["mustache","father","dad"],"category":"People & Body","description":"man","unicode_version":"6.0"},{"emoji":"🧔","aliases":["bearded_person"],"tags":[],"category":"People & Body","description":"person: beard","unicode_version":"11.0"},{"emoji":"🧔‍♂️","aliases":["man_beard"],"tags":[],"category":"People & Body","description":"man: beard","unicode_version":"13.1"},{"emoji":"🧔‍♀️","aliases":["woman_beard"],"tags":[],"category":"People & Body","description":"woman: beard","unicode_version":"13.1"},{"emoji":"👨‍🦰","aliases":["red_haired_man"],"tags":[],"category":"People & Body","description":"man: red hair","unicode_version":"11.0"},{"emoji":"👨‍🦱","aliases":["curly_haired_man"],"tags":[],"category":"People & Body","description":"man: curly hair","unicode_version":"11.0"},{"emoji":"👨‍🦳","aliases":["white_haired_man"],"tags":[],"category":"People & Body","description":"man: white hair","unicode_version":"11.0"},{"emoji":"👨‍🦲","aliases":["bald_man"],"tags":[],"category":"People & Body","description":"man: bald","unicode_version":"11.0"},{"emoji":"👩","aliases":["woman"],"tags":["girls"],"category":"People & Body","description":"woman","unicode_version":"6.0"},{"emoji":"👩‍🦰","aliases":["red_haired_woman"],"tags":[],"category":"People & Body","description":"woman: red hair","unicode_version":"11.0"},{"emoji":"🧑‍🦰","aliases":["person_red_hair"],"tags":[],"category":"People & Body","description":"person: red hair","unicode_version":"12.1"},{"emoji":"👩‍🦱","aliases":["curly_haired_woman"],"tags":[],"category":"People & Body","description":"woman: curly hair","unicode_version":"11.0"},{"emoji":"🧑‍🦱","aliases":["person_curly_hair"],"tags":[],"category":"People & Body","description":"person: curly hair","unicode_version":"12.1"},{"emoji":"👩‍🦳","aliases":["white_haired_woman"],"tags":[],"category":"People & Body","description":"woman: white hair","unicode_version":"11.0"},{"emoji":"🧑‍🦳","aliases":["person_white_hair"],"tags":[],"category":"People & Body","description":"person: white hair","unicode_version":"12.1"},{"emoji":"👩‍🦲","aliases":["bald_woman"],"tags":[],"category":"People & Body","description":"woman: bald","unicode_version":"11.0"},{"emoji":"🧑‍🦲","aliases":["person_bald"],"tags":[],"category":"People & Body","description":"person: bald","unicode_version":"12.1"},{"emoji":"👱‍♀️","aliases":["blond_haired_woman","blonde_woman"],"tags":[],"category":"People & Body","description":"woman: blond hair","unicode_version":"6.0"},{"emoji":"👱‍♂️","aliases":["blond_haired_man"],"tags":[],"category":"People & Body","description":"man: blond hair","unicode_version":"11.0"},{"emoji":"🧓","aliases":["older_adult"],"tags":[],"category":"People & Body","description":"older person","unicode_version":"11.0"},{"emoji":"👴","aliases":["older_man"],"tags":[],"category":"People & Body","description":"old man","unicode_version":"6.0"},{"emoji":"👵","aliases":["older_woman"],"tags":[],"category":"People & Body","description":"old woman","unicode_version":"6.0"},{"emoji":"🙍","aliases":["frowning_person"],"tags":[],"category":"People & Body","description":"person frowning","unicode_version":"6.0"},{"emoji":"🙍‍♂️","aliases":["frowning_man"],"tags":[],"category":"People & Body","description":"man frowning","unicode_version":"6.0"},{"emoji":"🙍‍♀️","aliases":["frowning_woman"],"tags":[],"category":"People & Body","description":"woman frowning","unicode_version":"11.0"},{"emoji":"🙎","aliases":["pouting_face"],"tags":[],"category":"People & Body","description":"person pouting","unicode_version":"6.0"},{"emoji":"🙎‍♂️","aliases":["pouting_man"],"tags":[],"category":"People & Body","description":"man pouting","unicode_version":"6.0"},{"emoji":"🙎‍♀️","aliases":["pouting_woman"],"tags":[],"category":"People & Body","description":"woman pouting","unicode_version":"11.0"},{"emoji":"🙅","aliases":["no_good"],"tags":["stop","halt","denied"],"category":"People & Body","description":"person gesturing NO","unicode_version":"6.0"},{"emoji":"🙅‍♂️","aliases":["no_good_man","ng_man"],"tags":["stop","halt","denied"],"category":"People & Body","description":"man gesturing NO","unicode_version":"6.0"},{"emoji":"🙅‍♀️","aliases":["no_good_woman","ng_woman"],"tags":["stop","halt","denied"],"category":"People & Body","description":"woman gesturing NO","unicode_version":"11.0"},{"emoji":"🙆","aliases":["ok_person"],"tags":[],"category":"People & Body","description":"person gesturing OK","unicode_version":"6.0"},{"emoji":"🙆‍♂️","aliases":["ok_man"],"tags":[],"category":"People & Body","description":"man gesturing OK","unicode_version":"6.0"},{"emoji":"🙆‍♀️","aliases":["ok_woman"],"tags":[],"category":"People & Body","description":"woman gesturing OK","unicode_version":"11.0"},{"emoji":"💁","aliases":["tipping_hand_person","information_desk_person"],"tags":[],"category":"People & Body","description":"person tipping hand","unicode_version":"6.0"},{"emoji":"💁‍♂️","aliases":["tipping_hand_man","sassy_man"],"tags":["information"],"category":"People & Body","description":"man tipping hand","unicode_version":"6.0"},{"emoji":"💁‍♀️","aliases":["tipping_hand_woman","sassy_woman"],"tags":["information"],"category":"People & Body","description":"woman tipping hand","unicode_version":"11.0"},{"emoji":"🙋","aliases":["raising_hand"],"tags":[],"category":"People & Body","description":"person raising hand","unicode_version":"6.0"},{"emoji":"🙋‍♂️","aliases":["raising_hand_man"],"tags":[],"category":"People & Body","description":"man raising hand","unicode_version":"6.0"},{"emoji":"🙋‍♀️","aliases":["raising_hand_woman"],"tags":[],"category":"People & Body","description":"woman raising hand","unicode_version":"11.0"},{"emoji":"🧏","aliases":["deaf_person"],"tags":[],"category":"People & Body","description":"deaf person","unicode_version":"12.0"},{"emoji":"🧏‍♂️","aliases":["deaf_man"],"tags":[],"category":"People & Body","description":"deaf man","unicode_version":"12.0"},{"emoji":"🧏‍♀️","aliases":["deaf_woman"],"tags":[],"category":"People & Body","description":"deaf woman","unicode_version":"12.0"},{"emoji":"🙇","aliases":["bow"],"tags":["respect","thanks"],"category":"People & Body","description":"person bowing","unicode_version":"6.0"},{"emoji":"🙇‍♂️","aliases":["bowing_man"],"tags":["respect","thanks"],"category":"People & Body","description":"man bowing","unicode_version":"11.0"},{"emoji":"🙇‍♀️","aliases":["bowing_woman"],"tags":["respect","thanks"],"category":"People & Body","description":"woman bowing","unicode_version":"6.0"},{"emoji":"🤦","aliases":["facepalm"],"tags":[],"category":"People & Body","description":"person facepalming","unicode_version":"11.0"},{"emoji":"🤦‍♂️","aliases":["man_facepalming"],"tags":[],"category":"People & Body","description":"man facepalming","unicode_version":"9.0"},{"emoji":"🤦‍♀️","aliases":["woman_facepalming"],"tags":[],"category":"People & Body","description":"woman facepalming","unicode_version":"9.0"},{"emoji":"🤷","aliases":["shrug"],"tags":[],"category":"People & Body","description":"person shrugging","unicode_version":"11.0"},{"emoji":"🤷‍♂️","aliases":["man_shrugging"],"tags":[],"category":"People & Body","description":"man shrugging","unicode_version":"9.0"},{"emoji":"🤷‍♀️","aliases":["woman_shrugging"],"tags":[],"category":"People & Body","description":"woman shrugging","unicode_version":"9.0"},{"emoji":"🧑‍⚕️","aliases":["health_worker"],"tags":[],"category":"People & Body","description":"health worker","unicode_version":"12.1"},{"emoji":"👨‍⚕️","aliases":["man_health_worker"],"tags":["doctor","nurse"],"category":"People & Body","description":"man health worker","unicode_version":""},{"emoji":"👩‍⚕️","aliases":["woman_health_worker"],"tags":["doctor","nurse"],"category":"People & Body","description":"woman health worker","unicode_version":""},{"emoji":"🧑‍🎓","aliases":["student"],"tags":[],"category":"People & Body","description":"student","unicode_version":"12.1"},{"emoji":"👨‍🎓","aliases":["man_student"],"tags":["graduation"],"category":"People & Body","description":"man student","unicode_version":""},{"emoji":"👩‍🎓","aliases":["woman_student"],"tags":["graduation"],"category":"People & Body","description":"woman student","unicode_version":""},{"emoji":"🧑‍🏫","aliases":["teacher"],"tags":[],"category":"People & Body","description":"teacher","unicode_version":"12.1"},{"emoji":"👨‍🏫","aliases":["man_teacher"],"tags":["school","professor"],"category":"People & Body","description":"man teacher","unicode_version":""},{"emoji":"👩‍🏫","aliases":["woman_teacher"],"tags":["school","professor"],"category":"People & Body","description":"woman teacher","unicode_version":""},{"emoji":"🧑‍⚖️","aliases":["judge"],"tags":[],"category":"People & Body","description":"judge","unicode_version":"12.1"},{"emoji":"👨‍⚖️","aliases":["man_judge"],"tags":["justice"],"category":"People & Body","description":"man judge","unicode_version":""},{"emoji":"👩‍⚖️","aliases":["woman_judge"],"tags":["justice"],"category":"People & Body","description":"woman judge","unicode_version":""},{"emoji":"🧑‍🌾","aliases":["farmer"],"tags":[],"category":"People & Body","description":"farmer","unicode_version":"12.1"},{"emoji":"👨‍🌾","aliases":["man_farmer"],"tags":[],"category":"People & Body","description":"man farmer","unicode_version":""},{"emoji":"👩‍🌾","aliases":["woman_farmer"],"tags":[],"category":"People & Body","description":"woman farmer","unicode_version":""},{"emoji":"🧑‍🍳","aliases":["cook"],"tags":[],"category":"People & Body","description":"cook","unicode_version":"12.1"},{"emoji":"👨‍🍳","aliases":["man_cook"],"tags":["chef"],"category":"People & Body","description":"man cook","unicode_version":""},{"emoji":"👩‍🍳","aliases":["woman_cook"],"tags":["chef"],"category":"People & Body","description":"woman cook","unicode_version":""},{"emoji":"🧑‍🔧","aliases":["mechanic"],"tags":[],"category":"People & Body","description":"mechanic","unicode_version":"12.1"},{"emoji":"👨‍🔧","aliases":["man_mechanic"],"tags":[],"category":"People & Body","description":"man mechanic","unicode_version":""},{"emoji":"👩‍🔧","aliases":["woman_mechanic"],"tags":[],"category":"People & Body","description":"woman mechanic","unicode_version":""},{"emoji":"🧑‍🏭","aliases":["factory_worker"],"tags":[],"category":"People & Body","description":"factory worker","unicode_version":"12.1"},{"emoji":"👨‍🏭","aliases":["man_factory_worker"],"tags":[],"category":"People & Body","description":"man factory worker","unicode_version":""},{"emoji":"👩‍🏭","aliases":["woman_factory_worker"],"tags":[],"category":"People & Body","description":"woman factory worker","unicode_version":""},{"emoji":"🧑‍💼","aliases":["office_worker"],"tags":[],"category":"People & Body","description":"office worker","unicode_version":"12.1"},{"emoji":"👨‍💼","aliases":["man_office_worker"],"tags":["business"],"category":"People & Body","description":"man office worker","unicode_version":""},{"emoji":"👩‍💼","aliases":["woman_office_worker"],"tags":["business"],"category":"People & Body","description":"woman office worker","unicode_version":""},{"emoji":"🧑‍🔬","aliases":["scientist"],"tags":[],"category":"People & Body","description":"scientist","unicode_version":"12.1"},{"emoji":"👨‍🔬","aliases":["man_scientist"],"tags":["research"],"category":"People & Body","description":"man scientist","unicode_version":""},{"emoji":"👩‍🔬","aliases":["woman_scientist"],"tags":["research"],"category":"People & Body","description":"woman scientist","unicode_version":""},{"emoji":"🧑‍💻","aliases":["technologist"],"tags":[],"category":"People & Body","description":"technologist","unicode_version":"12.1"},{"emoji":"👨‍💻","aliases":["man_technologist"],"tags":["coder"],"category":"People & Body","description":"man technologist","unicode_version":""},{"emoji":"👩‍💻","aliases":["woman_technologist"],"tags":["coder"],"category":"People & Body","description":"woman technologist","unicode_version":""},{"emoji":"🧑‍🎤","aliases":["singer"],"tags":[],"category":"People & Body","description":"singer","unicode_version":"12.1"},{"emoji":"👨‍🎤","aliases":["man_singer"],"tags":["rockstar"],"category":"People & Body","description":"man singer","unicode_version":""},{"emoji":"👩‍🎤","aliases":["woman_singer"],"tags":["rockstar"],"category":"People & Body","description":"woman singer","unicode_version":""},{"emoji":"🧑‍🎨","aliases":["artist"],"tags":[],"category":"People & Body","description":"artist","unicode_version":"12.1"},{"emoji":"👨‍🎨","aliases":["man_artist"],"tags":["painter"],"category":"People & Body","description":"man artist","unicode_version":""},{"emoji":"👩‍🎨","aliases":["woman_artist"],"tags":["painter"],"category":"People & Body","description":"woman artist","unicode_version":""},{"emoji":"🧑‍✈️","aliases":["pilot"],"tags":[],"category":"People & Body","description":"pilot","unicode_version":"12.1"},{"emoji":"👨‍✈️","aliases":["man_pilot"],"tags":[],"category":"People & Body","description":"man pilot","unicode_version":""},{"emoji":"👩‍✈️","aliases":["woman_pilot"],"tags":[],"category":"People & Body","description":"woman pilot","unicode_version":""},{"emoji":"🧑‍🚀","aliases":["astronaut"],"tags":[],"category":"People & Body","description":"astronaut","unicode_version":"12.1"},{"emoji":"👨‍🚀","aliases":["man_astronaut"],"tags":["space"],"category":"People & Body","description":"man astronaut","unicode_version":""},{"emoji":"👩‍🚀","aliases":["woman_astronaut"],"tags":["space"],"category":"People & Body","description":"woman astronaut","unicode_version":""},{"emoji":"🧑‍🚒","aliases":["firefighter"],"tags":[],"category":"People & Body","description":"firefighter","unicode_version":"12.1"},{"emoji":"👨‍🚒","aliases":["man_firefighter"],"tags":[],"category":"People & Body","description":"man firefighter","unicode_version":""},{"emoji":"👩‍🚒","aliases":["woman_firefighter"],"tags":[],"category":"People & Body","description":"woman firefighter","unicode_version":""},{"emoji":"👮","aliases":["police_officer","cop"],"tags":["law"],"category":"People & Body","description":"police officer","unicode_version":"6.0"},{"emoji":"👮‍♂️","aliases":["policeman"],"tags":["law","cop"],"category":"People & Body","description":"man police officer","unicode_version":"11.0"},{"emoji":"👮‍♀️","aliases":["policewoman"],"tags":["law","cop"],"category":"People & Body","description":"woman police officer","unicode_version":"6.0"},{"emoji":"🕵️","aliases":["detective"],"tags":["sleuth"],"category":"People & Body","description":"detective","unicode_version":"7.0"},{"emoji":"🕵️‍♂️","aliases":["male_detective"],"tags":["sleuth"],"category":"People & Body","description":"man detective","unicode_version":"11.0"},{"emoji":"🕵️‍♀️","aliases":["female_detective"],"tags":["sleuth"],"category":"People & Body","description":"woman detective","unicode_version":"6.0"},{"emoji":"💂","aliases":["guard"],"tags":[],"category":"People & Body","description":"guard","unicode_version":"6.0"},{"emoji":"💂‍♂️","aliases":["guardsman"],"tags":[],"category":"People & Body","description":"man guard","unicode_version":"11.0"},{"emoji":"💂‍♀️","aliases":["guardswoman"],"tags":[],"category":"People & Body","description":"woman guard","unicode_version":"6.0"},{"emoji":"🥷","aliases":["ninja"],"tags":[],"category":"People & Body","description":"ninja","unicode_version":"13.0"},{"emoji":"👷","aliases":["construction_worker"],"tags":["helmet"],"category":"People & Body","description":"construction worker","unicode_version":"6.0"},{"emoji":"👷‍♂️","aliases":["construction_worker_man"],"tags":["helmet"],"category":"People & Body","description":"man construction worker","unicode_version":"11.0"},{"emoji":"👷‍♀️","aliases":["construction_worker_woman"],"tags":["helmet"],"category":"People & Body","description":"woman construction worker","unicode_version":"6.0"},{"emoji":"🤴","aliases":["prince"],"tags":["crown","royal"],"category":"People & Body","description":"prince","unicode_version":"9.0"},{"emoji":"👸","aliases":["princess"],"tags":["crown","royal"],"category":"People & Body","description":"princess","unicode_version":"6.0"},{"emoji":"👳","aliases":["person_with_turban"],"tags":[],"category":"People & Body","description":"person wearing turban","unicode_version":"6.0"},{"emoji":"👳‍♂️","aliases":["man_with_turban"],"tags":[],"category":"People & Body","description":"man wearing turban","unicode_version":"11.0"},{"emoji":"👳‍♀️","aliases":["woman_with_turban"],"tags":[],"category":"People & Body","description":"woman wearing turban","unicode_version":"6.0"},{"emoji":"👲","aliases":["man_with_gua_pi_mao"],"tags":[],"category":"People & Body","description":"person with skullcap","unicode_version":"6.0"},{"emoji":"🧕","aliases":["woman_with_headscarf"],"tags":["hijab"],"category":"People & Body","description":"woman with headscarf","unicode_version":"11.0"},{"emoji":"🤵","aliases":["person_in_tuxedo"],"tags":["groom","marriage","wedding"],"category":"People & Body","description":"person in tuxedo","unicode_version":"9.0"},{"emoji":"🤵‍♂️","aliases":["man_in_tuxedo"],"tags":[],"category":"People & Body","description":"man in tuxedo","unicode_version":"13.0"},{"emoji":"🤵‍♀️","aliases":["woman_in_tuxedo"],"tags":[],"category":"People & Body","description":"woman in tuxedo","unicode_version":"13.0"},{"emoji":"👰","aliases":["person_with_veil"],"tags":["marriage","wedding"],"category":"People & Body","description":"person with veil","unicode_version":"6.0"},{"emoji":"👰‍♂️","aliases":["man_with_veil"],"tags":[],"category":"People & Body","description":"man with veil","unicode_version":"13.0"},{"emoji":"👰‍♀️","aliases":["woman_with_veil","bride_with_veil"],"tags":[],"category":"People & Body","description":"woman with veil","unicode_version":"13.0"},{"emoji":"🤰","aliases":["pregnant_woman"],"tags":[],"category":"People & Body","description":"pregnant woman","unicode_version":"9.0"},{"emoji":"🤱","aliases":["breast_feeding"],"tags":["nursing"],"category":"People & Body","description":"breast-feeding","unicode_version":"11.0"},{"emoji":"👩‍🍼","aliases":["woman_feeding_baby"],"tags":[],"category":"People & Body","description":"woman feeding baby","unicode_version":"13.0"},{"emoji":"👨‍🍼","aliases":["man_feeding_baby"],"tags":[],"category":"People & Body","description":"man feeding baby","unicode_version":"13.0"},{"emoji":"🧑‍🍼","aliases":["person_feeding_baby"],"tags":[],"category":"People & Body","description":"person feeding baby","unicode_version":"13.0"},{"emoji":"👼","aliases":["angel"],"tags":[],"category":"People & Body","description":"baby angel","unicode_version":"6.0"},{"emoji":"🎅","aliases":["santa"],"tags":["christmas"],"category":"People & Body","description":"Santa Claus","unicode_version":"6.0"},{"emoji":"🤶","aliases":["mrs_claus"],"tags":["santa"],"category":"People & Body","description":"Mrs. Claus","unicode_version":"9.0"},{"emoji":"🧑‍🎄","aliases":["mx_claus"],"tags":[],"category":"People & Body","description":"mx claus","unicode_version":"13.0"},{"emoji":"🦸","aliases":["superhero"],"tags":[],"category":"People & Body","description":"superhero","unicode_version":"11.0"},{"emoji":"🦸‍♂️","aliases":["superhero_man"],"tags":[],"category":"People & Body","description":"man superhero","unicode_version":"11.0"},{"emoji":"🦸‍♀️","aliases":["superhero_woman"],"tags":[],"category":"People & Body","description":"woman superhero","unicode_version":"11.0"},{"emoji":"🦹","aliases":["supervillain"],"tags":[],"category":"People & Body","description":"supervillain","unicode_version":"11.0"},{"emoji":"🦹‍♂️","aliases":["supervillain_man"],"tags":[],"category":"People & Body","description":"man supervillain","unicode_version":"11.0"},{"emoji":"🦹‍♀️","aliases":["supervillain_woman"],"tags":[],"category":"People & Body","description":"woman supervillain","unicode_version":"11.0"},{"emoji":"🧙","aliases":["mage"],"tags":["wizard"],"category":"People & Body","description":"mage","unicode_version":"11.0"},{"emoji":"🧙‍♂️","aliases":["mage_man"],"tags":["wizard"],"category":"People & Body","description":"man mage","unicode_version":"11.0"},{"emoji":"🧙‍♀️","aliases":["mage_woman"],"tags":["wizard"],"category":"People & Body","description":"woman mage","unicode_version":"11.0"},{"emoji":"🧚","aliases":["fairy"],"tags":[],"category":"People & Body","description":"fairy","unicode_version":"11.0"},{"emoji":"🧚‍♂️","aliases":["fairy_man"],"tags":[],"category":"People & Body","description":"man fairy","unicode_version":"11.0"},{"emoji":"🧚‍♀️","aliases":["fairy_woman"],"tags":[],"category":"People & Body","description":"woman fairy","unicode_version":"11.0"},{"emoji":"🧛","aliases":["vampire"],"tags":[],"category":"People & Body","description":"vampire","unicode_version":"11.0"},{"emoji":"🧛‍♂️","aliases":["vampire_man"],"tags":[],"category":"People & Body","description":"man vampire","unicode_version":"11.0"},{"emoji":"🧛‍♀️","aliases":["vampire_woman"],"tags":[],"category":"People & Body","description":"woman vampire","unicode_version":"11.0"},{"emoji":"🧜","aliases":["merperson"],"tags":[],"category":"People & Body","description":"merperson","unicode_version":"11.0"},{"emoji":"🧜‍♂️","aliases":["merman"],"tags":[],"category":"People & Body","description":"merman","unicode_version":"11.0"},{"emoji":"🧜‍♀️","aliases":["mermaid"],"tags":[],"category":"People & Body","description":"mermaid","unicode_version":"11.0"},{"emoji":"🧝","aliases":["elf"],"tags":[],"category":"People & Body","description":"elf","unicode_version":"11.0"},{"emoji":"🧝‍♂️","aliases":["elf_man"],"tags":[],"category":"People & Body","description":"man elf","unicode_version":"11.0"},{"emoji":"🧝‍♀️","aliases":["elf_woman"],"tags":[],"category":"People & Body","description":"woman elf","unicode_version":"11.0"},{"emoji":"🧞","aliases":["genie"],"tags":[],"category":"People & Body","description":"genie","unicode_version":"11.0"},{"emoji":"🧞‍♂️","aliases":["genie_man"],"tags":[],"category":"People & Body","description":"man genie","unicode_version":"11.0"},{"emoji":"🧞‍♀️","aliases":["genie_woman"],"tags":[],"category":"People & Body","description":"woman genie","unicode_version":"11.0"},{"emoji":"🧟","aliases":["zombie"],"tags":[],"category":"People & Body","description":"zombie","unicode_version":"11.0"},{"emoji":"🧟‍♂️","aliases":["zombie_man"],"tags":[],"category":"People & Body","description":"man zombie","unicode_version":"11.0"},{"emoji":"🧟‍♀️","aliases":["zombie_woman"],"tags":[],"category":"People & Body","description":"woman zombie","unicode_version":"11.0"},{"emoji":"💆","aliases":["massage"],"tags":["spa"],"category":"People & Body","description":"person getting massage","unicode_version":"6.0"},{"emoji":"💆‍♂️","aliases":["massage_man"],"tags":["spa"],"category":"People & Body","description":"man getting massage","unicode_version":"6.0"},{"emoji":"💆‍♀️","aliases":["massage_woman"],"tags":["spa"],"category":"People & Body","description":"woman getting massage","unicode_version":"11.0"},{"emoji":"💇","aliases":["haircut"],"tags":["beauty"],"category":"People & Body","description":"person getting haircut","unicode_version":"6.0"},{"emoji":"💇‍♂️","aliases":["haircut_man"],"tags":[],"category":"People & Body","description":"man getting haircut","unicode_version":"6.0"},{"emoji":"💇‍♀️","aliases":["haircut_woman"],"tags":[],"category":"People & Body","description":"woman getting haircut","unicode_version":"11.0"},{"emoji":"🚶","aliases":["walking"],"tags":[],"category":"People & Body","description":"person walking","unicode_version":"6.0"},{"emoji":"🚶‍♂️","aliases":["walking_man"],"tags":[],"category":"People & Body","description":"man walking","unicode_version":"11.0"},{"emoji":"🚶‍♀️","aliases":["walking_woman"],"tags":[],"category":"People & Body","description":"woman walking","unicode_version":"6.0"},{"emoji":"🧍","aliases":["standing_person"],"tags":[],"category":"People & Body","description":"person standing","unicode_version":"12.0"},{"emoji":"🧍‍♂️","aliases":["standing_man"],"tags":[],"category":"People & Body","description":"man standing","unicode_version":"12.0"},{"emoji":"🧍‍♀️","aliases":["standing_woman"],"tags":[],"category":"People & Body","description":"woman standing","unicode_version":"12.0"},{"emoji":"🧎","aliases":["kneeling_person"],"tags":[],"category":"People & Body","description":"person kneeling","unicode_version":"12.0"},{"emoji":"🧎‍♂️","aliases":["kneeling_man"],"tags":[],"category":"People & Body","description":"man kneeling","unicode_version":"12.0"},{"emoji":"🧎‍♀️","aliases":["kneeling_woman"],"tags":[],"category":"People & Body","description":"woman kneeling","unicode_version":"12.0"},{"emoji":"🧑‍🦯","aliases":["person_with_probing_cane"],"tags":[],"category":"People & Body","description":"person with white cane","unicode_version":"12.1"},{"emoji":"👨‍🦯","aliases":["man_with_probing_cane"],"tags":[],"category":"People & Body","description":"man with white cane","unicode_version":"12.0"},{"emoji":"👩‍🦯","aliases":["woman_with_probing_cane"],"tags":[],"category":"People & Body","description":"woman with white cane","unicode_version":"12.0"},{"emoji":"🧑‍🦼","aliases":["person_in_motorized_wheelchair"],"tags":[],"category":"People & Body","description":"person in motorized wheelchair","unicode_version":"12.1"},{"emoji":"👨‍🦼","aliases":["man_in_motorized_wheelchair"],"tags":[],"category":"People & Body","description":"man in motorized wheelchair","unicode_version":"12.0"},{"emoji":"👩‍🦼","aliases":["woman_in_motorized_wheelchair"],"tags":[],"category":"People & Body","description":"woman in motorized wheelchair","unicode_version":"12.0"},{"emoji":"🧑‍🦽","aliases":["person_in_manual_wheelchair"],"tags":[],"category":"People & Body","description":"person in manual wheelchair","unicode_version":"12.1"},{"emoji":"👨‍🦽","aliases":["man_in_manual_wheelchair"],"tags":[],"category":"People & Body","description":"man in manual wheelchair","unicode_version":"12.0"},{"emoji":"👩‍🦽","aliases":["woman_in_manual_wheelchair"],"tags":[],"category":"People & Body","description":"woman in manual wheelchair","unicode_version":"12.0"},{"emoji":"🏃","aliases":["runner","running"],"tags":["exercise","workout","marathon"],"category":"People & Body","description":"person running","unicode_version":"6.0"},{"emoji":"🏃‍♂️","aliases":["running_man"],"tags":["exercise","workout","marathon"],"category":"People & Body","description":"man running","unicode_version":"11.0"},{"emoji":"🏃‍♀️","aliases":["running_woman"],"tags":["exercise","workout","marathon"],"category":"People & Body","description":"woman running","unicode_version":"6.0"},{"emoji":"💃","aliases":["woman_dancing","dancer"],"tags":["dress"],"category":"People & Body","description":"woman dancing","unicode_version":"6.0"},{"emoji":"🕺","aliases":["man_dancing"],"tags":["dancer"],"category":"People & Body","description":"man dancing","unicode_version":"9.0"},{"emoji":"🕴️","aliases":["business_suit_levitating"],"tags":[],"category":"People & Body","description":"person in suit levitating","unicode_version":"7.0"},{"emoji":"👯","aliases":["dancers"],"tags":["bunny"],"category":"People & Body","description":"people with bunny ears","unicode_version":"6.0"},{"emoji":"👯‍♂️","aliases":["dancing_men"],"tags":["bunny"],"category":"People & Body","description":"men with bunny ears","unicode_version":"6.0"},{"emoji":"👯‍♀️","aliases":["dancing_women"],"tags":["bunny"],"category":"People & Body","description":"women with bunny ears","unicode_version":"11.0"},{"emoji":"🧖","aliases":["sauna_person"],"tags":["steamy"],"category":"People & Body","description":"person in steamy room","unicode_version":"11.0"},{"emoji":"🧖‍♂️","aliases":["sauna_man"],"tags":["steamy"],"category":"People & Body","description":"man in steamy room","unicode_version":"11.0"},{"emoji":"🧖‍♀️","aliases":["sauna_woman"],"tags":["steamy"],"category":"People & Body","description":"woman in steamy room","unicode_version":"11.0"},{"emoji":"🧗","aliases":["climbing"],"tags":["bouldering"],"category":"People & Body","description":"person climbing","unicode_version":"11.0"},{"emoji":"🧗‍♂️","aliases":["climbing_man"],"tags":["bouldering"],"category":"People & Body","description":"man climbing","unicode_version":"11.0"},{"emoji":"🧗‍♀️","aliases":["climbing_woman"],"tags":["bouldering"],"category":"People & Body","description":"woman climbing","unicode_version":"11.0"},{"emoji":"🤺","aliases":["person_fencing"],"tags":[],"category":"People & Body","description":"person fencing","unicode_version":"9.0"},{"emoji":"🏇","aliases":["horse_racing"],"tags":[],"category":"People & Body","description":"horse racing","unicode_version":"6.0"},{"emoji":"⛷️","aliases":["skier"],"tags":[],"category":"People & Body","description":"skier","unicode_version":"5.2"},{"emoji":"🏂","aliases":["snowboarder"],"tags":[],"category":"People & Body","description":"snowboarder","unicode_version":"6.0"},{"emoji":"🏌️","aliases":["golfing"],"tags":[],"category":"People & Body","description":"person golfing","unicode_version":"7.0"},{"emoji":"🏌️‍♂️","aliases":["golfing_man"],"tags":[],"category":"People & Body","description":"man golfing","unicode_version":"11.0"},{"emoji":"🏌️‍♀️","aliases":["golfing_woman"],"tags":[],"category":"People & Body","description":"woman golfing","unicode_version":""},{"emoji":"🏄","aliases":["surfer"],"tags":[],"category":"People & Body","description":"person surfing","unicode_version":"6.0"},{"emoji":"🏄‍♂️","aliases":["surfing_man"],"tags":[],"category":"People & Body","description":"man surfing","unicode_version":"11.0"},{"emoji":"🏄‍♀️","aliases":["surfing_woman"],"tags":[],"category":"People & Body","description":"woman surfing","unicode_version":"7.0"},{"emoji":"🚣","aliases":["rowboat"],"tags":[],"category":"People & Body","description":"person rowing boat","unicode_version":"6.0"},{"emoji":"🚣‍♂️","aliases":["rowing_man"],"tags":[],"category":"People & Body","description":"man rowing boat","unicode_version":"11.0"},{"emoji":"🚣‍♀️","aliases":["rowing_woman"],"tags":[],"category":"People & Body","description":"woman rowing boat","unicode_version":"6.0"},{"emoji":"🏊","aliases":["swimmer"],"tags":[],"category":"People & Body","description":"person swimming","unicode_version":"6.0"},{"emoji":"🏊‍♂️","aliases":["swimming_man"],"tags":[],"category":"People & Body","description":"man swimming","unicode_version":"11.0"},{"emoji":"🏊‍♀️","aliases":["swimming_woman"],"tags":[],"category":"People & Body","description":"woman swimming","unicode_version":"6.0"},{"emoji":"⛹️","aliases":["bouncing_ball_person"],"tags":["basketball"],"category":"People & Body","description":"person bouncing ball","unicode_version":"5.2"},{"emoji":"⛹️‍♂️","aliases":["bouncing_ball_man","basketball_man"],"tags":[],"category":"People & Body","description":"man bouncing ball","unicode_version":"11.0"},{"emoji":"⛹️‍♀️","aliases":["bouncing_ball_woman","basketball_woman"],"tags":[],"category":"People & Body","description":"woman bouncing ball","unicode_version":"7.0"},{"emoji":"🏋️","aliases":["weight_lifting"],"tags":["gym","workout"],"category":"People & Body","description":"person lifting weights","unicode_version":"7.0"},{"emoji":"🏋️‍♂️","aliases":["weight_lifting_man"],"tags":["gym","workout"],"category":"People & Body","description":"man lifting weights","unicode_version":"11.0"},{"emoji":"🏋️‍♀️","aliases":["weight_lifting_woman"],"tags":["gym","workout"],"category":"People & Body","description":"woman lifting weights","unicode_version":"6.0"},{"emoji":"🚴","aliases":["bicyclist"],"tags":[],"category":"People & Body","description":"person biking","unicode_version":"6.0"},{"emoji":"🚴‍♂️","aliases":["biking_man"],"tags":[],"category":"People & Body","description":"man biking","unicode_version":"11.0"},{"emoji":"🚴‍♀️","aliases":["biking_woman"],"tags":[],"category":"People & Body","description":"woman biking","unicode_version":"6.0"},{"emoji":"🚵","aliases":["mountain_bicyclist"],"tags":[],"category":"People & Body","description":"person mountain biking","unicode_version":"6.0"},{"emoji":"🚵‍♂️","aliases":["mountain_biking_man"],"tags":[],"category":"People & Body","description":"man mountain biking","unicode_version":"11.0"},{"emoji":"🚵‍♀️","aliases":["mountain_biking_woman"],"tags":[],"category":"People & Body","description":"woman mountain biking","unicode_version":"6.0"},{"emoji":"🤸","aliases":["cartwheeling"],"tags":[],"category":"People & Body","description":"person cartwheeling","unicode_version":"11.0"},{"emoji":"🤸‍♂️","aliases":["man_cartwheeling"],"tags":[],"category":"People & Body","description":"man cartwheeling","unicode_version":""},{"emoji":"🤸‍♀️","aliases":["woman_cartwheeling"],"tags":[],"category":"People & Body","description":"woman cartwheeling","unicode_version":""},{"emoji":"🤼","aliases":["wrestling"],"tags":[],"category":"People & Body","description":"people wrestling","unicode_version":"11.0"},{"emoji":"🤼‍♂️","aliases":["men_wrestling"],"tags":[],"category":"People & Body","description":"men wrestling","unicode_version":"9.0"},{"emoji":"🤼‍♀️","aliases":["women_wrestling"],"tags":[],"category":"People & Body","description":"women wrestling","unicode_version":"9.0"},{"emoji":"🤽","aliases":["water_polo"],"tags":[],"category":"People & Body","description":"person playing water polo","unicode_version":"11.0"},{"emoji":"🤽‍♂️","aliases":["man_playing_water_polo"],"tags":[],"category":"People & Body","description":"man playing water polo","unicode_version":"9.0"},{"emoji":"🤽‍♀️","aliases":["woman_playing_water_polo"],"tags":[],"category":"People & Body","description":"woman playing water polo","unicode_version":"9.0"},{"emoji":"🤾","aliases":["handball_person"],"tags":[],"category":"People & Body","description":"person playing handball","unicode_version":"11.0"},{"emoji":"🤾‍♂️","aliases":["man_playing_handball"],"tags":[],"category":"People & Body","description":"man playing handball","unicode_version":"9.0"},{"emoji":"🤾‍♀️","aliases":["woman_playing_handball"],"tags":[],"category":"People & Body","description":"woman playing handball","unicode_version":"9.0"},{"emoji":"🤹","aliases":["juggling_person"],"tags":[],"category":"People & Body","description":"person juggling","unicode_version":"11.0"},{"emoji":"🤹‍♂️","aliases":["man_juggling"],"tags":[],"category":"People & Body","description":"man juggling","unicode_version":"9.0"},{"emoji":"🤹‍♀️","aliases":["woman_juggling"],"tags":[],"category":"People & Body","description":"woman juggling","unicode_version":"9.0"},{"emoji":"🧘","aliases":["lotus_position"],"tags":["meditation"],"category":"People & Body","description":"person in lotus position","unicode_version":"11.0"},{"emoji":"🧘‍♂️","aliases":["lotus_position_man"],"tags":["meditation"],"category":"People & Body","description":"man in lotus position","unicode_version":"11.0"},{"emoji":"🧘‍♀️","aliases":["lotus_position_woman"],"tags":["meditation"],"category":"People & Body","description":"woman in lotus position","unicode_version":"11.0"},{"emoji":"🛀","aliases":["bath"],"tags":["shower"],"category":"People & Body","description":"person taking bath","unicode_version":"6.0"},{"emoji":"🛌","aliases":["sleeping_bed"],"tags":[],"category":"People & Body","description":"person in bed","unicode_version":"7.0"},{"emoji":"🧑‍🤝‍🧑","aliases":["people_holding_hands"],"tags":["couple","date"],"category":"People & Body","description":"people holding hands","unicode_version":"12.0"},{"emoji":"👭","aliases":["two_women_holding_hands"],"tags":["couple","date"],"category":"People & Body","description":"women holding hands","unicode_version":"6.0"},{"emoji":"👫","aliases":["couple"],"tags":["date"],"category":"People & Body","description":"woman and man holding hands","unicode_version":"6.0"},{"emoji":"👬","aliases":["two_men_holding_hands"],"tags":["couple","date"],"category":"People & Body","description":"men holding hands","unicode_version":"6.0"},{"emoji":"💏","aliases":["couplekiss"],"tags":[],"category":"People & Body","description":"kiss","unicode_version":"6.0"},{"emoji":"👩‍❤️‍💋‍👨","aliases":["couplekiss_man_woman"],"tags":[],"category":"People & Body","description":"kiss: woman, man","unicode_version":"11.0"},{"emoji":"👨‍❤️‍💋‍👨","aliases":["couplekiss_man_man"],"tags":[],"category":"People & Body","description":"kiss: man, man","unicode_version":"6.0"},{"emoji":"👩‍❤️‍💋‍👩","aliases":["couplekiss_woman_woman"],"tags":[],"category":"People & Body","description":"kiss: woman, woman","unicode_version":"6.0"},{"emoji":"💑","aliases":["couple_with_heart"],"tags":[],"category":"People & Body","description":"couple with heart","unicode_version":"6.0"},{"emoji":"👩‍❤️‍👨","aliases":["couple_with_heart_woman_man"],"tags":[],"category":"People & Body","description":"couple with heart: woman, man","unicode_version":"11.0"},{"emoji":"👨‍❤️‍👨","aliases":["couple_with_heart_man_man"],"tags":[],"category":"People & Body","description":"couple with heart: man, man","unicode_version":"6.0"},{"emoji":"👩‍❤️‍👩","aliases":["couple_with_heart_woman_woman"],"tags":[],"category":"People & Body","description":"couple with heart: woman, woman","unicode_version":"6.0"},{"emoji":"👪","aliases":["family"],"tags":["home","parents","child"],"category":"People & Body","description":"family","unicode_version":"6.0"},{"emoji":"👨‍👩‍👦","aliases":["family_man_woman_boy"],"tags":[],"category":"People & Body","description":"family: man, woman, boy","unicode_version":"11.0"},{"emoji":"👨‍👩‍👧","aliases":["family_man_woman_girl"],"tags":[],"category":"People & Body","description":"family: man, woman, girl","unicode_version":"6.0"},{"emoji":"👨‍👩‍👧‍👦","aliases":["family_man_woman_girl_boy"],"tags":[],"category":"People & Body","description":"family: man, woman, girl, boy","unicode_version":"6.0"},{"emoji":"👨‍👩‍👦‍👦","aliases":["family_man_woman_boy_boy"],"tags":[],"category":"People & Body","description":"family: man, woman, boy, boy","unicode_version":"6.0"},{"emoji":"👨‍👩‍👧‍👧","aliases":["family_man_woman_girl_girl"],"tags":[],"category":"People & Body","description":"family: man, woman, girl, girl","unicode_version":"6.0"},{"emoji":"👨‍👨‍👦","aliases":["family_man_man_boy"],"tags":[],"category":"People & Body","description":"family: man, man, boy","unicode_version":"6.0"},{"emoji":"👨‍👨‍👧","aliases":["family_man_man_girl"],"tags":[],"category":"People & Body","description":"family: man, man, girl","unicode_version":"6.0"},{"emoji":"👨‍👨‍👧‍👦","aliases":["family_man_man_girl_boy"],"tags":[],"category":"People & Body","description":"family: man, man, girl, boy","unicode_version":"6.0"},{"emoji":"👨‍👨‍👦‍👦","aliases":["family_man_man_boy_boy"],"tags":[],"category":"People & Body","description":"family: man, man, boy, boy","unicode_version":"6.0"},{"emoji":"👨‍👨‍👧‍👧","aliases":["family_man_man_girl_girl"],"tags":[],"category":"People & Body","description":"family: man, man, girl, girl","unicode_version":"6.0"},{"emoji":"👩‍👩‍👦","aliases":["family_woman_woman_boy"],"tags":[],"category":"People & Body","description":"family: woman, woman, boy","unicode_version":"6.0"},{"emoji":"👩‍👩‍👧","aliases":["family_woman_woman_girl"],"tags":[],"category":"People & Body","description":"family: woman, woman, girl","unicode_version":"6.0"},{"emoji":"👩‍👩‍👧‍👦","aliases":["family_woman_woman_girl_boy"],"tags":[],"category":"People & Body","description":"family: woman, woman, girl, boy","unicode_version":"6.0"},{"emoji":"👩‍👩‍👦‍👦","aliases":["family_woman_woman_boy_boy"],"tags":[],"category":"People & Body","description":"family: woman, woman, boy, boy","unicode_version":"6.0"},{"emoji":"👩‍👩‍👧‍👧","aliases":["family_woman_woman_girl_girl"],"tags":[],"category":"People & Body","description":"family: woman, woman, girl, girl","unicode_version":"6.0"},{"emoji":"👨‍👦","aliases":["family_man_boy"],"tags":[],"category":"People & Body","description":"family: man, boy","unicode_version":"6.0"},{"emoji":"👨‍👦‍👦","aliases":["family_man_boy_boy"],"tags":[],"category":"People & Body","description":"family: man, boy, boy","unicode_version":"6.0"},{"emoji":"👨‍👧","aliases":["family_man_girl"],"tags":[],"category":"People & Body","description":"family: man, girl","unicode_version":"6.0"},{"emoji":"👨‍👧‍👦","aliases":["family_man_girl_boy"],"tags":[],"category":"People & Body","description":"family: man, girl, boy","unicode_version":"6.0"},{"emoji":"👨‍👧‍👧","aliases":["family_man_girl_girl"],"tags":[],"category":"People & Body","description":"family: man, girl, girl","unicode_version":"6.0"},{"emoji":"👩‍👦","aliases":["family_woman_boy"],"tags":[],"category":"People & Body","description":"family: woman, boy","unicode_version":"6.0"},{"emoji":"👩‍👦‍👦","aliases":["family_woman_boy_boy"],"tags":[],"category":"People & Body","description":"family: woman, boy, boy","unicode_version":"6.0"},{"emoji":"👩‍👧","aliases":["family_woman_girl"],"tags":[],"category":"People & Body","description":"family: woman, girl","unicode_version":"6.0"},{"emoji":"👩‍👧‍👦","aliases":["family_woman_girl_boy"],"tags":[],"category":"People & Body","description":"family: woman, girl, boy","unicode_version":"6.0"},{"emoji":"👩‍👧‍👧","aliases":["family_woman_girl_girl"],"tags":[],"category":"People & Body","description":"family: woman, girl, girl","unicode_version":"6.0"},{"emoji":"🗣️","aliases":["speaking_head"],"tags":[],"category":"People & Body","description":"speaking head","unicode_version":"7.0"},{"emoji":"👤","aliases":["bust_in_silhouette"],"tags":["user"],"category":"People & Body","description":"bust in silhouette","unicode_version":"6.0"},{"emoji":"👥","aliases":["busts_in_silhouette"],"tags":["users","group","team"],"category":"People & Body","description":"busts in silhouette","unicode_version":"6.0"},{"emoji":"🫂","aliases":["people_hugging"],"tags":[],"category":"People & Body","description":"people hugging","unicode_version":"13.0"},{"emoji":"👣","aliases":["footprints"],"tags":["feet","tracks"],"category":"People & Body","description":"footprints","unicode_version":"6.0"},{"emoji":"🐵","aliases":["monkey_face"],"tags":[],"category":"Animals & Nature","description":"monkey face","unicode_version":"6.0"},{"emoji":"🐒","aliases":["monkey"],"tags":[],"category":"Animals & Nature","description":"monkey","unicode_version":"6.0"},{"emoji":"🦍","aliases":["gorilla"],"tags":[],"category":"Animals & Nature","description":"gorilla","unicode_version":"9.0"},{"emoji":"🦧","aliases":["orangutan"],"tags":[],"category":"Animals & Nature","description":"orangutan","unicode_version":"12.0"},{"emoji":"🐶","aliases":["dog"],"tags":["pet"],"category":"Animals & Nature","description":"dog face","unicode_version":"6.0"},{"emoji":"🐕","aliases":["dog2"],"tags":[],"category":"Animals & Nature","description":"dog","unicode_version":"6.0"},{"emoji":"🦮","aliases":["guide_dog"],"tags":[],"category":"Animals & Nature","description":"guide dog","unicode_version":"12.0"},{"emoji":"🐕‍🦺","aliases":["service_dog"],"tags":[],"category":"Animals & Nature","description":"service dog","unicode_version":"12.0"},{"emoji":"🐩","aliases":["poodle"],"tags":["dog"],"category":"Animals & Nature","description":"poodle","unicode_version":"6.0"},{"emoji":"🐺","aliases":["wolf"],"tags":[],"category":"Animals & Nature","description":"wolf","unicode_version":"6.0"},{"emoji":"🦊","aliases":["fox_face"],"tags":[],"category":"Animals & Nature","description":"fox","unicode_version":"9.0"},{"emoji":"🦝","aliases":["raccoon"],"tags":[],"category":"Animals & Nature","description":"raccoon","unicode_version":"11.0"},{"emoji":"🐱","aliases":["cat"],"tags":["pet"],"category":"Animals & Nature","description":"cat face","unicode_version":"6.0"},{"emoji":"🐈","aliases":["cat2"],"tags":[],"category":"Animals & Nature","description":"cat","unicode_version":"6.0"},{"emoji":"🐈‍⬛","aliases":["black_cat"],"tags":[],"category":"Animals & Nature","description":"black cat","unicode_version":"13.0"},{"emoji":"🦁","aliases":["lion"],"tags":[],"category":"Animals & Nature","description":"lion","unicode_version":"8.0"},{"emoji":"🐯","aliases":["tiger"],"tags":[],"category":"Animals & Nature","description":"tiger face","unicode_version":"6.0"},{"emoji":"🐅","aliases":["tiger2"],"tags":[],"category":"Animals & Nature","description":"tiger","unicode_version":"6.0"},{"emoji":"🐆","aliases":["leopard"],"tags":[],"category":"Animals & Nature","description":"leopard","unicode_version":"6.0"},{"emoji":"🐴","aliases":["horse"],"tags":[],"category":"Animals & Nature","description":"horse face","unicode_version":"6.0"},{"emoji":"🐎","aliases":["racehorse"],"tags":["speed"],"category":"Animals & Nature","description":"horse","unicode_version":"6.0"},{"emoji":"🦄","aliases":["unicorn"],"tags":[],"category":"Animals & Nature","description":"unicorn","unicode_version":"8.0"},{"emoji":"🦓","aliases":["zebra"],"tags":[],"category":"Animals & Nature","description":"zebra","unicode_version":"11.0"},{"emoji":"🦌","aliases":["deer"],"tags":[],"category":"Animals & Nature","description":"deer","unicode_version":"9.0"},{"emoji":"🦬","aliases":["bison"],"tags":[],"category":"Animals & Nature","description":"bison","unicode_version":"13.0"},{"emoji":"🐮","aliases":["cow"],"tags":[],"category":"Animals & Nature","description":"cow face","unicode_version":"6.0"},{"emoji":"🐂","aliases":["ox"],"tags":[],"category":"Animals & Nature","description":"ox","unicode_version":"6.0"},{"emoji":"🐃","aliases":["water_buffalo"],"tags":[],"category":"Animals & Nature","description":"water buffalo","unicode_version":"6.0"},{"emoji":"🐄","aliases":["cow2"],"tags":[],"category":"Animals & Nature","description":"cow","unicode_version":"6.0"},{"emoji":"🐷","aliases":["pig"],"tags":[],"category":"Animals & Nature","description":"pig face","unicode_version":"6.0"},{"emoji":"🐖","aliases":["pig2"],"tags":[],"category":"Animals & Nature","description":"pig","unicode_version":"6.0"},{"emoji":"🐗","aliases":["boar"],"tags":[],"category":"Animals & Nature","description":"boar","unicode_version":"6.0"},{"emoji":"🐽","aliases":["pig_nose"],"tags":[],"category":"Animals & Nature","description":"pig nose","unicode_version":"6.0"},{"emoji":"🐏","aliases":["ram"],"tags":[],"category":"Animals & Nature","description":"ram","unicode_version":"6.0"},{"emoji":"🐑","aliases":["sheep"],"tags":[],"category":"Animals & Nature","description":"ewe","unicode_version":"6.0"},{"emoji":"🐐","aliases":["goat"],"tags":[],"category":"Animals & Nature","description":"goat","unicode_version":"6.0"},{"emoji":"🐪","aliases":["dromedary_camel"],"tags":["desert"],"category":"Animals & Nature","description":"camel","unicode_version":"6.0"},{"emoji":"🐫","aliases":["camel"],"tags":[],"category":"Animals & Nature","description":"two-hump camel","unicode_version":"6.0"},{"emoji":"🦙","aliases":["llama"],"tags":[],"category":"Animals & Nature","description":"llama","unicode_version":"11.0"},{"emoji":"🦒","aliases":["giraffe"],"tags":[],"category":"Animals & Nature","description":"giraffe","unicode_version":"11.0"},{"emoji":"🐘","aliases":["elephant"],"tags":[],"category":"Animals & Nature","description":"elephant","unicode_version":"6.0"},{"emoji":"🦣","aliases":["mammoth"],"tags":[],"category":"Animals & Nature","description":"mammoth","unicode_version":"13.0"},{"emoji":"🦏","aliases":["rhinoceros"],"tags":[],"category":"Animals & Nature","description":"rhinoceros","unicode_version":"9.0"},{"emoji":"🦛","aliases":["hippopotamus"],"tags":[],"category":"Animals & Nature","description":"hippopotamus","unicode_version":"11.0"},{"emoji":"🐭","aliases":["mouse"],"tags":[],"category":"Animals & Nature","description":"mouse face","unicode_version":"6.0"},{"emoji":"🐁","aliases":["mouse2"],"tags":[],"category":"Animals & Nature","description":"mouse","unicode_version":"6.0"},{"emoji":"🐀","aliases":["rat"],"tags":[],"category":"Animals & Nature","description":"rat","unicode_version":"6.0"},{"emoji":"🐹","aliases":["hamster"],"tags":["pet"],"category":"Animals & Nature","description":"hamster","unicode_version":"6.0"},{"emoji":"🐰","aliases":["rabbit"],"tags":["bunny"],"category":"Animals & Nature","description":"rabbit face","unicode_version":"6.0"},{"emoji":"🐇","aliases":["rabbit2"],"tags":[],"category":"Animals & Nature","description":"rabbit","unicode_version":"6.0"},{"emoji":"🐿️","aliases":["chipmunk"],"tags":[],"category":"Animals & Nature","description":"chipmunk","unicode_version":"7.0"},{"emoji":"🦫","aliases":["beaver"],"tags":[],"category":"Animals & Nature","description":"beaver","unicode_version":"13.0"},{"emoji":"🦔","aliases":["hedgehog"],"tags":[],"category":"Animals & Nature","description":"hedgehog","unicode_version":"11.0"},{"emoji":"🦇","aliases":["bat"],"tags":[],"category":"Animals & Nature","description":"bat","unicode_version":"9.0"},{"emoji":"🐻","aliases":["bear"],"tags":[],"category":"Animals & Nature","description":"bear","unicode_version":"6.0"},{"emoji":"🐻‍❄️","aliases":["polar_bear"],"tags":[],"category":"Animals & Nature","description":"polar bear","unicode_version":"13.0"},{"emoji":"🐨","aliases":["koala"],"tags":[],"category":"Animals & Nature","description":"koala","unicode_version":"6.0"},{"emoji":"🐼","aliases":["panda_face"],"tags":[],"category":"Animals & Nature","description":"panda","unicode_version":"6.0"},{"emoji":"🦥","aliases":["sloth"],"tags":[],"category":"Animals & Nature","description":"sloth","unicode_version":"12.0"},{"emoji":"🦦","aliases":["otter"],"tags":[],"category":"Animals & Nature","description":"otter","unicode_version":"12.0"},{"emoji":"🦨","aliases":["skunk"],"tags":[],"category":"Animals & Nature","description":"skunk","unicode_version":"12.0"},{"emoji":"🦘","aliases":["kangaroo"],"tags":[],"category":"Animals & Nature","description":"kangaroo","unicode_version":"11.0"},{"emoji":"🦡","aliases":["badger"],"tags":[],"category":"Animals & Nature","description":"badger","unicode_version":"11.0"},{"emoji":"🐾","aliases":["feet","paw_prints"],"tags":[],"category":"Animals & Nature","description":"paw prints","unicode_version":"6.0"},{"emoji":"🦃","aliases":["turkey"],"tags":["thanksgiving"],"category":"Animals & Nature","description":"turkey","unicode_version":"8.0"},{"emoji":"🐔","aliases":["chicken"],"tags":[],"category":"Animals & Nature","description":"chicken","unicode_version":"6.0"},{"emoji":"🐓","aliases":["rooster"],"tags":[],"category":"Animals & Nature","description":"rooster","unicode_version":"6.0"},{"emoji":"🐣","aliases":["hatching_chick"],"tags":[],"category":"Animals & Nature","description":"hatching chick","unicode_version":"6.0"},{"emoji":"🐤","aliases":["baby_chick"],"tags":[],"category":"Animals & Nature","description":"baby chick","unicode_version":"6.0"},{"emoji":"🐥","aliases":["hatched_chick"],"tags":[],"category":"Animals & Nature","description":"front-facing baby chick","unicode_version":"6.0"},{"emoji":"🐦","aliases":["bird"],"tags":[],"category":"Animals & Nature","description":"bird","unicode_version":"6.0"},{"emoji":"🐧","aliases":["penguin"],"tags":[],"category":"Animals & Nature","description":"penguin","unicode_version":"6.0"},{"emoji":"🕊️","aliases":["dove"],"tags":["peace"],"category":"Animals & Nature","description":"dove","unicode_version":"7.0"},{"emoji":"🦅","aliases":["eagle"],"tags":[],"category":"Animals & Nature","description":"eagle","unicode_version":"9.0"},{"emoji":"🦆","aliases":["duck"],"tags":[],"category":"Animals & Nature","description":"duck","unicode_version":"9.0"},{"emoji":"🦢","aliases":["swan"],"tags":[],"category":"Animals & Nature","description":"swan","unicode_version":"11.0"},{"emoji":"🦉","aliases":["owl"],"tags":[],"category":"Animals & Nature","description":"owl","unicode_version":"9.0"},{"emoji":"🦤","aliases":["dodo"],"tags":[],"category":"Animals & Nature","description":"dodo","unicode_version":"13.0"},{"emoji":"🪶","aliases":["feather"],"tags":[],"category":"Animals & Nature","description":"feather","unicode_version":"13.0"},{"emoji":"🦩","aliases":["flamingo"],"tags":[],"category":"Animals & Nature","description":"flamingo","unicode_version":"12.0"},{"emoji":"🦚","aliases":["peacock"],"tags":[],"category":"Animals & Nature","description":"peacock","unicode_version":"11.0"},{"emoji":"🦜","aliases":["parrot"],"tags":[],"category":"Animals & Nature","description":"parrot","unicode_version":"11.0"},{"emoji":"🐸","aliases":["frog"],"tags":[],"category":"Animals & Nature","description":"frog","unicode_version":"6.0"},{"emoji":"🐊","aliases":["crocodile"],"tags":[],"category":"Animals & Nature","description":"crocodile","unicode_version":"6.0"},{"emoji":"🐢","aliases":["turtle"],"tags":["slow"],"category":"Animals & Nature","description":"turtle","unicode_version":"6.0"},{"emoji":"🦎","aliases":["lizard"],"tags":[],"category":"Animals & Nature","description":"lizard","unicode_version":"9.0"},{"emoji":"🐍","aliases":["snake"],"tags":[],"category":"Animals & Nature","description":"snake","unicode_version":"6.0"},{"emoji":"🐲","aliases":["dragon_face"],"tags":[],"category":"Animals & Nature","description":"dragon face","unicode_version":"6.0"},{"emoji":"🐉","aliases":["dragon"],"tags":[],"category":"Animals & Nature","description":"dragon","unicode_version":"6.0"},{"emoji":"🦕","aliases":["sauropod"],"tags":["dinosaur"],"category":"Animals & Nature","description":"sauropod","unicode_version":"11.0"},{"emoji":"🦖","aliases":["t-rex"],"tags":["dinosaur"],"category":"Animals & Nature","description":"T-Rex","unicode_version":"11.0"},{"emoji":"🐳","aliases":["whale"],"tags":["sea"],"category":"Animals & Nature","description":"spouting whale","unicode_version":"6.0"},{"emoji":"🐋","aliases":["whale2"],"tags":[],"category":"Animals & Nature","description":"whale","unicode_version":"6.0"},{"emoji":"🐬","aliases":["dolphin","flipper"],"tags":[],"category":"Animals & Nature","description":"dolphin","unicode_version":"6.0"},{"emoji":"🦭","aliases":["seal"],"tags":[],"category":"Animals & Nature","description":"seal","unicode_version":"13.0"},{"emoji":"🐟","aliases":["fish"],"tags":[],"category":"Animals & Nature","description":"fish","unicode_version":"6.0"},{"emoji":"🐠","aliases":["tropical_fish"],"tags":[],"category":"Animals & Nature","description":"tropical fish","unicode_version":"6.0"},{"emoji":"🐡","aliases":["blowfish"],"tags":[],"category":"Animals & Nature","description":"blowfish","unicode_version":"6.0"},{"emoji":"🦈","aliases":["shark"],"tags":[],"category":"Animals & Nature","description":"shark","unicode_version":"9.0"},{"emoji":"🐙","aliases":["octopus"],"tags":[],"category":"Animals & Nature","description":"octopus","unicode_version":"6.0"},{"emoji":"🐚","aliases":["shell"],"tags":["sea","beach"],"category":"Animals & Nature","description":"spiral shell","unicode_version":"6.0"},{"emoji":"🐌","aliases":["snail"],"tags":["slow"],"category":"Animals & Nature","description":"snail","unicode_version":"6.0"},{"emoji":"🦋","aliases":["butterfly"],"tags":[],"category":"Animals & Nature","description":"butterfly","unicode_version":"9.0"},{"emoji":"🐛","aliases":["bug"],"tags":[],"category":"Animals & Nature","description":"bug","unicode_version":"6.0"},{"emoji":"🐜","aliases":["ant"],"tags":[],"category":"Animals & Nature","description":"ant","unicode_version":"6.0"},{"emoji":"🐝","aliases":["bee","honeybee"],"tags":[],"category":"Animals & Nature","description":"honeybee","unicode_version":"6.0"},{"emoji":"🪲","aliases":["beetle"],"tags":[],"category":"Animals & Nature","description":"beetle","unicode_version":"13.0"},{"emoji":"🐞","aliases":["lady_beetle"],"tags":["bug"],"category":"Animals & Nature","description":"lady beetle","unicode_version":"6.0"},{"emoji":"🦗","aliases":["cricket"],"tags":[],"category":"Animals & Nature","description":"cricket","unicode_version":"11.0"},{"emoji":"🪳","aliases":["cockroach"],"tags":[],"category":"Animals & Nature","description":"cockroach","unicode_version":"13.0"},{"emoji":"🕷️","aliases":["spider"],"tags":[],"category":"Animals & Nature","description":"spider","unicode_version":"7.0"},{"emoji":"🕸️","aliases":["spider_web"],"tags":[],"category":"Animals & Nature","description":"spider web","unicode_version":"7.0"},{"emoji":"🦂","aliases":["scorpion"],"tags":[],"category":"Animals & Nature","description":"scorpion","unicode_version":"8.0"},{"emoji":"🦟","aliases":["mosquito"],"tags":[],"category":"Animals & Nature","description":"mosquito","unicode_version":"11.0"},{"emoji":"🪰","aliases":["fly"],"tags":[],"category":"Animals & Nature","description":"fly","unicode_version":"13.0"},{"emoji":"🪱","aliases":["worm"],"tags":[],"category":"Animals & Nature","description":"worm","unicode_version":"13.0"},{"emoji":"🦠","aliases":["microbe"],"tags":["germ"],"category":"Animals & Nature","description":"microbe","unicode_version":"11.0"},{"emoji":"💐","aliases":["bouquet"],"tags":["flowers"],"category":"Animals & Nature","description":"bouquet","unicode_version":"6.0"},{"emoji":"🌸","aliases":["cherry_blossom"],"tags":["flower","spring"],"category":"Animals & Nature","description":"cherry blossom","unicode_version":"6.0"},{"emoji":"💮","aliases":["white_flower"],"tags":[],"category":"Animals & Nature","description":"white flower","unicode_version":"6.0"},{"emoji":"🏵️","aliases":["rosette"],"tags":[],"category":"Animals & Nature","description":"rosette","unicode_version":"7.0"},{"emoji":"🌹","aliases":["rose"],"tags":["flower"],"category":"Animals & Nature","description":"rose","unicode_version":"6.0"},{"emoji":"🥀","aliases":["wilted_flower"],"tags":[],"category":"Animals & Nature","description":"wilted flower","unicode_version":"9.0"},{"emoji":"🌺","aliases":["hibiscus"],"tags":[],"category":"Animals & Nature","description":"hibiscus","unicode_version":"6.0"},{"emoji":"🌻","aliases":["sunflower"],"tags":[],"category":"Animals & Nature","description":"sunflower","unicode_version":"6.0"},{"emoji":"🌼","aliases":["blossom"],"tags":[],"category":"Animals & Nature","description":"blossom","unicode_version":"6.0"},{"emoji":"🌷","aliases":["tulip"],"tags":["flower"],"category":"Animals & Nature","description":"tulip","unicode_version":"6.0"},{"emoji":"🌱","aliases":["seedling"],"tags":["plant"],"category":"Animals & Nature","description":"seedling","unicode_version":"6.0"},{"emoji":"🪴","aliases":["potted_plant"],"tags":[],"category":"Animals & Nature","description":"potted plant","unicode_version":"13.0"},{"emoji":"🌲","aliases":["evergreen_tree"],"tags":["wood"],"category":"Animals & Nature","description":"evergreen tree","unicode_version":"6.0"},{"emoji":"🌳","aliases":["deciduous_tree"],"tags":["wood"],"category":"Animals & Nature","description":"deciduous tree","unicode_version":"6.0"},{"emoji":"🌴","aliases":["palm_tree"],"tags":[],"category":"Animals & Nature","description":"palm tree","unicode_version":"6.0"},{"emoji":"🌵","aliases":["cactus"],"tags":[],"category":"Animals & Nature","description":"cactus","unicode_version":"6.0"},{"emoji":"🌾","aliases":["ear_of_rice"],"tags":[],"category":"Animals & Nature","description":"sheaf of rice","unicode_version":"6.0"},{"emoji":"🌿","aliases":["herb"],"tags":[],"category":"Animals & Nature","description":"herb","unicode_version":"6.0"},{"emoji":"☘️","aliases":["shamrock"],"tags":[],"category":"Animals & Nature","description":"shamrock","unicode_version":"4.1"},{"emoji":"🍀","aliases":["four_leaf_clover"],"tags":["luck"],"category":"Animals & Nature","description":"four leaf clover","unicode_version":"6.0"},{"emoji":"🍁","aliases":["maple_leaf"],"tags":["canada"],"category":"Animals & Nature","description":"maple leaf","unicode_version":"6.0"},{"emoji":"🍂","aliases":["fallen_leaf"],"tags":["autumn"],"category":"Animals & Nature","description":"fallen leaf","unicode_version":"6.0"},{"emoji":"🍃","aliases":["leaves"],"tags":["leaf"],"category":"Animals & Nature","description":"leaf fluttering in wind","unicode_version":"6.0"},{"emoji":"🍇","aliases":["grapes"],"tags":[],"category":"Food & Drink","description":"grapes","unicode_version":"6.0"},{"emoji":"🍈","aliases":["melon"],"tags":[],"category":"Food & Drink","description":"melon","unicode_version":"6.0"},{"emoji":"🍉","aliases":["watermelon"],"tags":[],"category":"Food & Drink","description":"watermelon","unicode_version":"6.0"},{"emoji":"🍊","aliases":["tangerine","orange","mandarin"],"tags":[],"category":"Food & Drink","description":"tangerine","unicode_version":"6.0"},{"emoji":"🍋","aliases":["lemon"],"tags":[],"category":"Food & Drink","description":"lemon","unicode_version":"6.0"},{"emoji":"🍌","aliases":["banana"],"tags":["fruit"],"category":"Food & Drink","description":"banana","unicode_version":"6.0"},{"emoji":"🍍","aliases":["pineapple"],"tags":[],"category":"Food & Drink","description":"pineapple","unicode_version":"6.0"},{"emoji":"🥭","aliases":["mango"],"tags":[],"category":"Food & Drink","description":"mango","unicode_version":"11.0"},{"emoji":"🍎","aliases":["apple"],"tags":[],"category":"Food & Drink","description":"red apple","unicode_version":"6.0"},{"emoji":"🍏","aliases":["green_apple"],"tags":["fruit"],"category":"Food & Drink","description":"green apple","unicode_version":"6.0"},{"emoji":"🍐","aliases":["pear"],"tags":[],"category":"Food & Drink","description":"pear","unicode_version":"6.0"},{"emoji":"🍑","aliases":["peach"],"tags":[],"category":"Food & Drink","description":"peach","unicode_version":"6.0"},{"emoji":"🍒","aliases":["cherries"],"tags":["fruit"],"category":"Food & Drink","description":"cherries","unicode_version":"6.0"},{"emoji":"🍓","aliases":["strawberry"],"tags":["fruit"],"category":"Food & Drink","description":"strawberry","unicode_version":"6.0"},{"emoji":"🫐","aliases":["blueberries"],"tags":[],"category":"Food & Drink","description":"blueberries","unicode_version":"13.0"},{"emoji":"🥝","aliases":["kiwi_fruit"],"tags":[],"category":"Food & Drink","description":"kiwi fruit","unicode_version":"9.0"},{"emoji":"🍅","aliases":["tomato"],"tags":[],"category":"Food & Drink","description":"tomato","unicode_version":"6.0"},{"emoji":"🫒","aliases":["olive"],"tags":[],"category":"Food & Drink","description":"olive","unicode_version":"13.0"},{"emoji":"🥥","aliases":["coconut"],"tags":[],"category":"Food & Drink","description":"coconut","unicode_version":"11.0"},{"emoji":"🥑","aliases":["avocado"],"tags":[],"category":"Food & Drink","description":"avocado","unicode_version":"9.0"},{"emoji":"🍆","aliases":["eggplant"],"tags":["aubergine"],"category":"Food & Drink","description":"eggplant","unicode_version":"6.0"},{"emoji":"🥔","aliases":["potato"],"tags":[],"category":"Food & Drink","description":"potato","unicode_version":"9.0"},{"emoji":"🥕","aliases":["carrot"],"tags":[],"category":"Food & Drink","description":"carrot","unicode_version":"9.0"},{"emoji":"🌽","aliases":["corn"],"tags":[],"category":"Food & Drink","description":"ear of corn","unicode_version":"6.0"},{"emoji":"🌶️","aliases":["hot_pepper"],"tags":["spicy"],"category":"Food & Drink","description":"hot pepper","unicode_version":"7.0"},{"emoji":"🫑","aliases":["bell_pepper"],"tags":[],"category":"Food & Drink","description":"bell pepper","unicode_version":"13.0"},{"emoji":"🥒","aliases":["cucumber"],"tags":[],"category":"Food & Drink","description":"cucumber","unicode_version":"9.0"},{"emoji":"🥬","aliases":["leafy_green"],"tags":[],"category":"Food & Drink","description":"leafy green","unicode_version":"11.0"},{"emoji":"🥦","aliases":["broccoli"],"tags":[],"category":"Food & Drink","description":"broccoli","unicode_version":"11.0"},{"emoji":"🧄","aliases":["garlic"],"tags":[],"category":"Food & Drink","description":"garlic","unicode_version":"12.0"},{"emoji":"🧅","aliases":["onion"],"tags":[],"category":"Food & Drink","description":"onion","unicode_version":"12.0"},{"emoji":"🍄","aliases":["mushroom"],"tags":[],"category":"Food & Drink","description":"mushroom","unicode_version":"6.0"},{"emoji":"🥜","aliases":["peanuts"],"tags":[],"category":"Food & Drink","description":"peanuts","unicode_version":"9.0"},{"emoji":"🌰","aliases":["chestnut"],"tags":[],"category":"Food & Drink","description":"chestnut","unicode_version":"6.0"},{"emoji":"🍞","aliases":["bread"],"tags":["toast"],"category":"Food & Drink","description":"bread","unicode_version":"6.0"},{"emoji":"🥐","aliases":["croissant"],"tags":[],"category":"Food & Drink","description":"croissant","unicode_version":"9.0"},{"emoji":"🥖","aliases":["baguette_bread"],"tags":[],"category":"Food & Drink","description":"baguette bread","unicode_version":"9.0"},{"emoji":"🫓","aliases":["flatbread"],"tags":[],"category":"Food & Drink","description":"flatbread","unicode_version":"13.0"},{"emoji":"🥨","aliases":["pretzel"],"tags":[],"category":"Food & Drink","description":"pretzel","unicode_version":"11.0"},{"emoji":"🥯","aliases":["bagel"],"tags":[],"category":"Food & Drink","description":"bagel","unicode_version":"11.0"},{"emoji":"🥞","aliases":["pancakes"],"tags":[],"category":"Food & Drink","description":"pancakes","unicode_version":"9.0"},{"emoji":"🧇","aliases":["waffle"],"tags":[],"category":"Food & Drink","description":"waffle","unicode_version":"12.0"},{"emoji":"🧀","aliases":["cheese"],"tags":[],"category":"Food & Drink","description":"cheese wedge","unicode_version":"8.0"},{"emoji":"🍖","aliases":["meat_on_bone"],"tags":[],"category":"Food & Drink","description":"meat on bone","unicode_version":"6.0"},{"emoji":"🍗","aliases":["poultry_leg"],"tags":["meat","chicken"],"category":"Food & Drink","description":"poultry leg","unicode_version":"6.0"},{"emoji":"🥩","aliases":["cut_of_meat"],"tags":[],"category":"Food & Drink","description":"cut of meat","unicode_version":"11.0"},{"emoji":"🥓","aliases":["bacon"],"tags":[],"category":"Food & Drink","description":"bacon","unicode_version":"9.0"},{"emoji":"🍔","aliases":["hamburger"],"tags":["burger"],"category":"Food & Drink","description":"hamburger","unicode_version":"6.0"},{"emoji":"🍟","aliases":["fries"],"tags":[],"category":"Food & Drink","description":"french fries","unicode_version":"6.0"},{"emoji":"🍕","aliases":["pizza"],"tags":[],"category":"Food & Drink","description":"pizza","unicode_version":"6.0"},{"emoji":"🌭","aliases":["hotdog"],"tags":[],"category":"Food & Drink","description":"hot dog","unicode_version":"8.0"},{"emoji":"🥪","aliases":["sandwich"],"tags":[],"category":"Food & Drink","description":"sandwich","unicode_version":"11.0"},{"emoji":"🌮","aliases":["taco"],"tags":[],"category":"Food & Drink","description":"taco","unicode_version":"8.0"},{"emoji":"🌯","aliases":["burrito"],"tags":[],"category":"Food & Drink","description":"burrito","unicode_version":"8.0"},{"emoji":"🫔","aliases":["tamale"],"tags":[],"category":"Food & Drink","description":"tamale","unicode_version":"13.0"},{"emoji":"🥙","aliases":["stuffed_flatbread"],"tags":[],"category":"Food & Drink","description":"stuffed flatbread","unicode_version":"9.0"},{"emoji":"🧆","aliases":["falafel"],"tags":[],"category":"Food & Drink","description":"falafel","unicode_version":"12.0"},{"emoji":"🥚","aliases":["egg"],"tags":[],"category":"Food & Drink","description":"egg","unicode_version":"9.0"},{"emoji":"🍳","aliases":["fried_egg"],"tags":["breakfast"],"category":"Food & Drink","description":"cooking","unicode_version":"6.0"},{"emoji":"🥘","aliases":["shallow_pan_of_food"],"tags":["paella","curry"],"category":"Food & Drink","description":"shallow pan of food","unicode_version":""},{"emoji":"🍲","aliases":["stew"],"tags":[],"category":"Food & Drink","description":"pot of food","unicode_version":"6.0"},{"emoji":"🫕","aliases":["fondue"],"tags":[],"category":"Food & Drink","description":"fondue","unicode_version":"13.0"},{"emoji":"🥣","aliases":["bowl_with_spoon"],"tags":[],"category":"Food & Drink","description":"bowl with spoon","unicode_version":"11.0"},{"emoji":"🥗","aliases":["green_salad"],"tags":[],"category":"Food & Drink","description":"green salad","unicode_version":"9.0"},{"emoji":"🍿","aliases":["popcorn"],"tags":[],"category":"Food & Drink","description":"popcorn","unicode_version":"8.0"},{"emoji":"🧈","aliases":["butter"],"tags":[],"category":"Food & Drink","description":"butter","unicode_version":"12.0"},{"emoji":"🧂","aliases":["salt"],"tags":[],"category":"Food & Drink","description":"salt","unicode_version":"11.0"},{"emoji":"🥫","aliases":["canned_food"],"tags":[],"category":"Food & Drink","description":"canned food","unicode_version":"11.0"},{"emoji":"🍱","aliases":["bento"],"tags":[],"category":"Food & Drink","description":"bento box","unicode_version":"6.0"},{"emoji":"🍘","aliases":["rice_cracker"],"tags":[],"category":"Food & Drink","description":"rice cracker","unicode_version":"6.0"},{"emoji":"🍙","aliases":["rice_ball"],"tags":[],"category":"Food & Drink","description":"rice ball","unicode_version":"6.0"},{"emoji":"🍚","aliases":["rice"],"tags":[],"category":"Food & Drink","description":"cooked rice","unicode_version":"6.0"},{"emoji":"🍛","aliases":["curry"],"tags":[],"category":"Food & Drink","description":"curry rice","unicode_version":"6.0"},{"emoji":"🍜","aliases":["ramen"],"tags":["noodle"],"category":"Food & Drink","description":"steaming bowl","unicode_version":"6.0"},{"emoji":"🍝","aliases":["spaghetti"],"tags":["pasta"],"category":"Food & Drink","description":"spaghetti","unicode_version":"6.0"},{"emoji":"🍠","aliases":["sweet_potato"],"tags":[],"category":"Food & Drink","description":"roasted sweet potato","unicode_version":"6.0"},{"emoji":"🍢","aliases":["oden"],"tags":[],"category":"Food & Drink","description":"oden","unicode_version":"6.0"},{"emoji":"🍣","aliases":["sushi"],"tags":[],"category":"Food & Drink","description":"sushi","unicode_version":"6.0"},{"emoji":"🍤","aliases":["fried_shrimp"],"tags":["tempura"],"category":"Food & Drink","description":"fried shrimp","unicode_version":"6.0"},{"emoji":"🍥","aliases":["fish_cake"],"tags":[],"category":"Food & Drink","description":"fish cake with swirl","unicode_version":"6.0"},{"emoji":"🥮","aliases":["moon_cake"],"tags":[],"category":"Food & Drink","description":"moon cake","unicode_version":"11.0"},{"emoji":"🍡","aliases":["dango"],"tags":[],"category":"Food & Drink","description":"dango","unicode_version":"6.0"},{"emoji":"🥟","aliases":["dumpling"],"tags":[],"category":"Food & Drink","description":"dumpling","unicode_version":"11.0"},{"emoji":"🥠","aliases":["fortune_cookie"],"tags":[],"category":"Food & Drink","description":"fortune cookie","unicode_version":"11.0"},{"emoji":"🥡","aliases":["takeout_box"],"tags":[],"category":"Food & Drink","description":"takeout box","unicode_version":"11.0"},{"emoji":"🦀","aliases":["crab"],"tags":[],"category":"Food & Drink","description":"crab","unicode_version":"8.0"},{"emoji":"🦞","aliases":["lobster"],"tags":[],"category":"Food & Drink","description":"lobster","unicode_version":"11.0"},{"emoji":"🦐","aliases":["shrimp"],"tags":[],"category":"Food & Drink","description":"shrimp","unicode_version":"9.0"},{"emoji":"🦑","aliases":["squid"],"tags":[],"category":"Food & Drink","description":"squid","unicode_version":"9.0"},{"emoji":"🦪","aliases":["oyster"],"tags":[],"category":"Food & Drink","description":"oyster","unicode_version":"12.0"},{"emoji":"🍦","aliases":["icecream"],"tags":[],"category":"Food & Drink","description":"soft ice cream","unicode_version":"6.0"},{"emoji":"🍧","aliases":["shaved_ice"],"tags":[],"category":"Food & Drink","description":"shaved ice","unicode_version":"6.0"},{"emoji":"🍨","aliases":["ice_cream"],"tags":[],"category":"Food & Drink","description":"ice cream","unicode_version":"6.0"},{"emoji":"🍩","aliases":["doughnut"],"tags":[],"category":"Food & Drink","description":"doughnut","unicode_version":"6.0"},{"emoji":"🍪","aliases":["cookie"],"tags":[],"category":"Food & Drink","description":"cookie","unicode_version":"6.0"},{"emoji":"🎂","aliases":["birthday"],"tags":["party"],"category":"Food & Drink","description":"birthday cake","unicode_version":"6.0"},{"emoji":"🍰","aliases":["cake"],"tags":["dessert"],"category":"Food & Drink","description":"shortcake","unicode_version":"6.0"},{"emoji":"🧁","aliases":["cupcake"],"tags":[],"category":"Food & Drink","description":"cupcake","unicode_version":"11.0"},{"emoji":"🥧","aliases":["pie"],"tags":[],"category":"Food & Drink","description":"pie","unicode_version":"11.0"},{"emoji":"🍫","aliases":["chocolate_bar"],"tags":[],"category":"Food & Drink","description":"chocolate bar","unicode_version":"6.0"},{"emoji":"🍬","aliases":["candy"],"tags":["sweet"],"category":"Food & Drink","description":"candy","unicode_version":"6.0"},{"emoji":"🍭","aliases":["lollipop"],"tags":[],"category":"Food & Drink","description":"lollipop","unicode_version":"6.0"},{"emoji":"🍮","aliases":["custard"],"tags":[],"category":"Food & Drink","description":"custard","unicode_version":"6.0"},{"emoji":"🍯","aliases":["honey_pot"],"tags":[],"category":"Food & Drink","description":"honey pot","unicode_version":"6.0"},{"emoji":"🍼","aliases":["baby_bottle"],"tags":["milk"],"category":"Food & Drink","description":"baby bottle","unicode_version":"6.0"},{"emoji":"🥛","aliases":["milk_glass"],"tags":[],"category":"Food & Drink","description":"glass of milk","unicode_version":"9.0"},{"emoji":"☕","aliases":["coffee"],"tags":["cafe","espresso"],"category":"Food & Drink","description":"hot beverage","unicode_version":"4.0"},{"emoji":"🫖","aliases":["teapot"],"tags":[],"category":"Food & Drink","description":"teapot","unicode_version":"13.0"},{"emoji":"🍵","aliases":["tea"],"tags":["green","breakfast"],"category":"Food & Drink","description":"teacup without handle","unicode_version":"6.0"},{"emoji":"🍶","aliases":["sake"],"tags":[],"category":"Food & Drink","description":"sake","unicode_version":"6.0"},{"emoji":"🍾","aliases":["champagne"],"tags":["bottle","bubbly","celebration"],"category":"Food & Drink","description":"bottle with popping cork","unicode_version":"8.0"},{"emoji":"🍷","aliases":["wine_glass"],"tags":[],"category":"Food & Drink","description":"wine glass","unicode_version":"6.0"},{"emoji":"🍸","aliases":["cocktail"],"tags":["drink"],"category":"Food & Drink","description":"cocktail glass","unicode_version":"6.0"},{"emoji":"🍹","aliases":["tropical_drink"],"tags":["summer","vacation"],"category":"Food & Drink","description":"tropical drink","unicode_version":"6.0"},{"emoji":"🍺","aliases":["beer"],"tags":["drink"],"category":"Food & Drink","description":"beer mug","unicode_version":"6.0"},{"emoji":"🍻","aliases":["beers"],"tags":["drinks"],"category":"Food & Drink","description":"clinking beer mugs","unicode_version":"6.0"},{"emoji":"🥂","aliases":["clinking_glasses"],"tags":["cheers","toast"],"category":"Food & Drink","description":"clinking glasses","unicode_version":"9.0"},{"emoji":"🥃","aliases":["tumbler_glass"],"tags":["whisky"],"category":"Food & Drink","description":"tumbler glass","unicode_version":"9.0"},{"emoji":"🥤","aliases":["cup_with_straw"],"tags":[],"category":"Food & Drink","description":"cup with straw","unicode_version":"11.0"},{"emoji":"🧋","aliases":["bubble_tea"],"tags":[],"category":"Food & Drink","description":"bubble tea","unicode_version":"13.0"},{"emoji":"🧃","aliases":["beverage_box"],"tags":[],"category":"Food & Drink","description":"beverage box","unicode_version":"12.0"},{"emoji":"🧉","aliases":["mate"],"tags":[],"category":"Food & Drink","description":"mate","unicode_version":"12.0"},{"emoji":"🧊","aliases":["ice_cube"],"tags":[],"category":"Food & Drink","description":"ice","unicode_version":"12.0"},{"emoji":"🥢","aliases":["chopsticks"],"tags":[],"category":"Food & Drink","description":"chopsticks","unicode_version":"11.0"},{"emoji":"🍽️","aliases":["plate_with_cutlery"],"tags":["dining","dinner"],"category":"Food & Drink","description":"fork and knife with plate","unicode_version":"7.0"},{"emoji":"🍴","aliases":["fork_and_knife"],"tags":["cutlery"],"category":"Food & Drink","description":"fork and knife","unicode_version":"6.0"},{"emoji":"🥄","aliases":["spoon"],"tags":[],"category":"Food & Drink","description":"spoon","unicode_version":"9.0"},{"emoji":"🔪","aliases":["hocho","knife"],"tags":["cut","chop"],"category":"Food & Drink","description":"kitchen knife","unicode_version":"6.0"},{"emoji":"🏺","aliases":["amphora"],"tags":[],"category":"Food & Drink","description":"amphora","unicode_version":"8.0"},{"emoji":"🌍","aliases":["earth_africa"],"tags":["globe","world","international"],"category":"Travel & Places","description":"globe showing Europe-Africa","unicode_version":"6.0"},{"emoji":"🌎","aliases":["earth_americas"],"tags":["globe","world","international"],"category":"Travel & Places","description":"globe showing Americas","unicode_version":"6.0"},{"emoji":"🌏","aliases":["earth_asia"],"tags":["globe","world","international"],"category":"Travel & Places","description":"globe showing Asia-Australia","unicode_version":"6.0"},{"emoji":"🌐","aliases":["globe_with_meridians"],"tags":["world","global","international"],"category":"Travel & Places","description":"globe with meridians","unicode_version":"6.0"},{"emoji":"🗺️","aliases":["world_map"],"tags":["travel"],"category":"Travel & Places","description":"world map","unicode_version":"7.0"},{"emoji":"🗾","aliases":["japan"],"tags":[],"category":"Travel & Places","description":"map of Japan","unicode_version":"6.0"},{"emoji":"🧭","aliases":["compass"],"tags":[],"category":"Travel & Places","description":"compass","unicode_version":"11.0"},{"emoji":"🏔️","aliases":["mountain_snow"],"tags":[],"category":"Travel & Places","description":"snow-capped mountain","unicode_version":"7.0"},{"emoji":"⛰️","aliases":["mountain"],"tags":[],"category":"Travel & Places","description":"mountain","unicode_version":"5.2"},{"emoji":"🌋","aliases":["volcano"],"tags":[],"category":"Travel & Places","description":"volcano","unicode_version":"6.0"},{"emoji":"🗻","aliases":["mount_fuji"],"tags":[],"category":"Travel & Places","description":"mount fuji","unicode_version":"6.0"},{"emoji":"🏕️","aliases":["camping"],"tags":[],"category":"Travel & Places","description":"camping","unicode_version":"7.0"},{"emoji":"🏖️","aliases":["beach_umbrella"],"tags":[],"category":"Travel & Places","description":"beach with umbrella","unicode_version":"7.0"},{"emoji":"🏜️","aliases":["desert"],"tags":[],"category":"Travel & Places","description":"desert","unicode_version":"7.0"},{"emoji":"🏝️","aliases":["desert_island"],"tags":[],"category":"Travel & Places","description":"desert island","unicode_version":"7.0"},{"emoji":"🏞️","aliases":["national_park"],"tags":[],"category":"Travel & Places","description":"national park","unicode_version":"7.0"},{"emoji":"🏟️","aliases":["stadium"],"tags":[],"category":"Travel & Places","description":"stadium","unicode_version":"7.0"},{"emoji":"🏛️","aliases":["classical_building"],"tags":[],"category":"Travel & Places","description":"classical building","unicode_version":"7.0"},{"emoji":"🏗️","aliases":["building_construction"],"tags":[],"category":"Travel & Places","description":"building construction","unicode_version":"7.0"},{"emoji":"🧱","aliases":["bricks"],"tags":[],"category":"Travel & Places","description":"brick","unicode_version":"11.0"},{"emoji":"🪨","aliases":["rock"],"tags":[],"category":"Travel & Places","description":"rock","unicode_version":"13.0"},{"emoji":"🪵","aliases":["wood"],"tags":[],"category":"Travel & Places","description":"wood","unicode_version":"13.0"},{"emoji":"🛖","aliases":["hut"],"tags":[],"category":"Travel & Places","description":"hut","unicode_version":"13.0"},{"emoji":"🏘️","aliases":["houses"],"tags":[],"category":"Travel & Places","description":"houses","unicode_version":"7.0"},{"emoji":"🏚️","aliases":["derelict_house"],"tags":[],"category":"Travel & Places","description":"derelict house","unicode_version":"7.0"},{"emoji":"🏠","aliases":["house"],"tags":[],"category":"Travel & Places","description":"house","unicode_version":"6.0"},{"emoji":"🏡","aliases":["house_with_garden"],"tags":[],"category":"Travel & Places","description":"house with garden","unicode_version":"6.0"},{"emoji":"🏢","aliases":["office"],"tags":[],"category":"Travel & Places","description":"office building","unicode_version":"6.0"},{"emoji":"🏣","aliases":["post_office"],"tags":[],"category":"Travel & Places","description":"Japanese post office","unicode_version":"6.0"},{"emoji":"🏤","aliases":["european_post_office"],"tags":[],"category":"Travel & Places","description":"post office","unicode_version":"6.0"},{"emoji":"🏥","aliases":["hospital"],"tags":[],"category":"Travel & Places","description":"hospital","unicode_version":"6.0"},{"emoji":"🏦","aliases":["bank"],"tags":[],"category":"Travel & Places","description":"bank","unicode_version":"6.0"},{"emoji":"🏨","aliases":["hotel"],"tags":[],"category":"Travel & Places","description":"hotel","unicode_version":"6.0"},{"emoji":"🏩","aliases":["love_hotel"],"tags":[],"category":"Travel & Places","description":"love hotel","unicode_version":"6.0"},{"emoji":"🏪","aliases":["convenience_store"],"tags":[],"category":"Travel & Places","description":"convenience store","unicode_version":"6.0"},{"emoji":"🏫","aliases":["school"],"tags":[],"category":"Travel & Places","description":"school","unicode_version":"6.0"},{"emoji":"🏬","aliases":["department_store"],"tags":[],"category":"Travel & Places","description":"department store","unicode_version":"6.0"},{"emoji":"🏭","aliases":["factory"],"tags":[],"category":"Travel & Places","description":"factory","unicode_version":"6.0"},{"emoji":"🏯","aliases":["japanese_castle"],"tags":[],"category":"Travel & Places","description":"Japanese castle","unicode_version":"6.0"},{"emoji":"🏰","aliases":["european_castle"],"tags":[],"category":"Travel & Places","description":"castle","unicode_version":"6.0"},{"emoji":"💒","aliases":["wedding"],"tags":["marriage"],"category":"Travel & Places","description":"wedding","unicode_version":"6.0"},{"emoji":"🗼","aliases":["tokyo_tower"],"tags":[],"category":"Travel & Places","description":"Tokyo tower","unicode_version":"6.0"},{"emoji":"🗽","aliases":["statue_of_liberty"],"tags":[],"category":"Travel & Places","description":"Statue of Liberty","unicode_version":"6.0"},{"emoji":"⛪","aliases":["church"],"tags":[],"category":"Travel & Places","description":"church","unicode_version":"5.2"},{"emoji":"🕌","aliases":["mosque"],"tags":[],"category":"Travel & Places","description":"mosque","unicode_version":"8.0"},{"emoji":"🛕","aliases":["hindu_temple"],"tags":[],"category":"Travel & Places","description":"hindu temple","unicode_version":"12.0"},{"emoji":"🕍","aliases":["synagogue"],"tags":[],"category":"Travel & Places","description":"synagogue","unicode_version":"8.0"},{"emoji":"⛩️","aliases":["shinto_shrine"],"tags":[],"category":"Travel & Places","description":"shinto shrine","unicode_version":"5.2"},{"emoji":"🕋","aliases":["kaaba"],"tags":[],"category":"Travel & Places","description":"kaaba","unicode_version":"8.0"},{"emoji":"⛲","aliases":["fountain"],"tags":[],"category":"Travel & Places","description":"fountain","unicode_version":"5.2"},{"emoji":"⛺","aliases":["tent"],"tags":["camping"],"category":"Travel & Places","description":"tent","unicode_version":"5.2"},{"emoji":"🌁","aliases":["foggy"],"tags":["karl"],"category":"Travel & Places","description":"foggy","unicode_version":"6.0"},{"emoji":"🌃","aliases":["night_with_stars"],"tags":[],"category":"Travel & Places","description":"night with stars","unicode_version":"6.0"},{"emoji":"🏙️","aliases":["cityscape"],"tags":["skyline"],"category":"Travel & Places","description":"cityscape","unicode_version":"7.0"},{"emoji":"🌄","aliases":["sunrise_over_mountains"],"tags":[],"category":"Travel & Places","description":"sunrise over mountains","unicode_version":"6.0"},{"emoji":"🌅","aliases":["sunrise"],"tags":[],"category":"Travel & Places","description":"sunrise","unicode_version":"6.0"},{"emoji":"🌆","aliases":["city_sunset"],"tags":[],"category":"Travel & Places","description":"cityscape at dusk","unicode_version":"6.0"},{"emoji":"🌇","aliases":["city_sunrise"],"tags":[],"category":"Travel & Places","description":"sunset","unicode_version":"6.0"},{"emoji":"🌉","aliases":["bridge_at_night"],"tags":[],"category":"Travel & Places","description":"bridge at night","unicode_version":"6.0"},{"emoji":"♨️","aliases":["hotsprings"],"tags":[],"category":"Travel & Places","description":"hot springs","unicode_version":""},{"emoji":"🎠","aliases":["carousel_horse"],"tags":[],"category":"Travel & Places","description":"carousel horse","unicode_version":"6.0"},{"emoji":"🎡","aliases":["ferris_wheel"],"tags":[],"category":"Travel & Places","description":"ferris wheel","unicode_version":"6.0"},{"emoji":"🎢","aliases":["roller_coaster"],"tags":[],"category":"Travel & Places","description":"roller coaster","unicode_version":"6.0"},{"emoji":"💈","aliases":["barber"],"tags":[],"category":"Travel & Places","description":"barber pole","unicode_version":"6.0"},{"emoji":"🎪","aliases":["circus_tent"],"tags":[],"category":"Travel & Places","description":"circus tent","unicode_version":"6.0"},{"emoji":"🚂","aliases":["steam_locomotive"],"tags":["train"],"category":"Travel & Places","description":"locomotive","unicode_version":"6.0"},{"emoji":"🚃","aliases":["railway_car"],"tags":[],"category":"Travel & Places","description":"railway car","unicode_version":"6.0"},{"emoji":"🚄","aliases":["bullettrain_side"],"tags":["train"],"category":"Travel & Places","description":"high-speed train","unicode_version":"6.0"},{"emoji":"🚅","aliases":["bullettrain_front"],"tags":["train"],"category":"Travel & Places","description":"bullet train","unicode_version":"6.0"},{"emoji":"🚆","aliases":["train2"],"tags":[],"category":"Travel & Places","description":"train","unicode_version":"6.0"},{"emoji":"🚇","aliases":["metro"],"tags":[],"category":"Travel & Places","description":"metro","unicode_version":"6.0"},{"emoji":"🚈","aliases":["light_rail"],"tags":[],"category":"Travel & Places","description":"light rail","unicode_version":"6.0"},{"emoji":"🚉","aliases":["station"],"tags":[],"category":"Travel & Places","description":"station","unicode_version":"6.0"},{"emoji":"🚊","aliases":["tram"],"tags":[],"category":"Travel & Places","description":"tram","unicode_version":"6.0"},{"emoji":"🚝","aliases":["monorail"],"tags":[],"category":"Travel & Places","description":"monorail","unicode_version":"6.0"},{"emoji":"🚞","aliases":["mountain_railway"],"tags":[],"category":"Travel & Places","description":"mountain railway","unicode_version":"6.0"},{"emoji":"🚋","aliases":["train"],"tags":[],"category":"Travel & Places","description":"tram car","unicode_version":"6.0"},{"emoji":"🚌","aliases":["bus"],"tags":[],"category":"Travel & Places","description":"bus","unicode_version":"6.0"},{"emoji":"🚍","aliases":["oncoming_bus"],"tags":[],"category":"Travel & Places","description":"oncoming bus","unicode_version":"6.0"},{"emoji":"🚎","aliases":["trolleybus"],"tags":[],"category":"Travel & Places","description":"trolleybus","unicode_version":"6.0"},{"emoji":"🚐","aliases":["minibus"],"tags":[],"category":"Travel & Places","description":"minibus","unicode_version":"6.0"},{"emoji":"🚑","aliases":["ambulance"],"tags":[],"category":"Travel & Places","description":"ambulance","unicode_version":"6.0"},{"emoji":"🚒","aliases":["fire_engine"],"tags":[],"category":"Travel & Places","description":"fire engine","unicode_version":"6.0"},{"emoji":"🚓","aliases":["police_car"],"tags":[],"category":"Travel & Places","description":"police car","unicode_version":"6.0"},{"emoji":"🚔","aliases":["oncoming_police_car"],"tags":[],"category":"Travel & Places","description":"oncoming police car","unicode_version":"6.0"},{"emoji":"🚕","aliases":["taxi"],"tags":[],"category":"Travel & Places","description":"taxi","unicode_version":"6.0"},{"emoji":"🚖","aliases":["oncoming_taxi"],"tags":[],"category":"Travel & Places","description":"oncoming taxi","unicode_version":"6.0"},{"emoji":"🚗","aliases":["car","red_car"],"tags":[],"category":"Travel & Places","description":"automobile","unicode_version":"6.0"},{"emoji":"🚘","aliases":["oncoming_automobile"],"tags":[],"category":"Travel & Places","description":"oncoming automobile","unicode_version":"6.0"},{"emoji":"🚙","aliases":["blue_car"],"tags":[],"category":"Travel & Places","description":"sport utility vehicle","unicode_version":"6.0"},{"emoji":"🛻","aliases":["pickup_truck"],"tags":[],"category":"Travel & Places","description":"pickup truck","unicode_version":"13.0"},{"emoji":"🚚","aliases":["truck"],"tags":[],"category":"Travel & Places","description":"delivery truck","unicode_version":"6.0"},{"emoji":"🚛","aliases":["articulated_lorry"],"tags":[],"category":"Travel & Places","description":"articulated lorry","unicode_version":"6.0"},{"emoji":"🚜","aliases":["tractor"],"tags":[],"category":"Travel & Places","description":"tractor","unicode_version":"6.0"},{"emoji":"🏎️","aliases":["racing_car"],"tags":[],"category":"Travel & Places","description":"racing car","unicode_version":"7.0"},{"emoji":"🏍️","aliases":["motorcycle"],"tags":[],"category":"Travel & Places","description":"motorcycle","unicode_version":"7.0"},{"emoji":"🛵","aliases":["motor_scooter"],"tags":[],"category":"Travel & Places","description":"motor scooter","unicode_version":"9.0"},{"emoji":"🦽","aliases":["manual_wheelchair"],"tags":[],"category":"Travel & Places","description":"manual wheelchair","unicode_version":"12.0"},{"emoji":"🦼","aliases":["motorized_wheelchair"],"tags":[],"category":"Travel & Places","description":"motorized wheelchair","unicode_version":"12.0"},{"emoji":"🛺","aliases":["auto_rickshaw"],"tags":[],"category":"Travel & Places","description":"auto rickshaw","unicode_version":"12.0"},{"emoji":"🚲","aliases":["bike"],"tags":["bicycle"],"category":"Travel & Places","description":"bicycle","unicode_version":"6.0"},{"emoji":"🛴","aliases":["kick_scooter"],"tags":[],"category":"Travel & Places","description":"kick scooter","unicode_version":"9.0"},{"emoji":"🛹","aliases":["skateboard"],"tags":[],"category":"Travel & Places","description":"skateboard","unicode_version":"11.0"},{"emoji":"🛼","aliases":["roller_skate"],"tags":[],"category":"Travel & Places","description":"roller skate","unicode_version":"13.0"},{"emoji":"🚏","aliases":["busstop"],"tags":[],"category":"Travel & Places","description":"bus stop","unicode_version":"6.0"},{"emoji":"🛣️","aliases":["motorway"],"tags":[],"category":"Travel & Places","description":"motorway","unicode_version":"7.0"},{"emoji":"🛤️","aliases":["railway_track"],"tags":[],"category":"Travel & Places","description":"railway track","unicode_version":"7.0"},{"emoji":"🛢️","aliases":["oil_drum"],"tags":[],"category":"Travel & Places","description":"oil drum","unicode_version":"7.0"},{"emoji":"⛽","aliases":["fuelpump"],"tags":[],"category":"Travel & Places","description":"fuel pump","unicode_version":"5.2"},{"emoji":"🚨","aliases":["rotating_light"],"tags":["911","emergency"],"category":"Travel & Places","description":"police car light","unicode_version":"6.0"},{"emoji":"🚥","aliases":["traffic_light"],"tags":[],"category":"Travel & Places","description":"horizontal traffic light","unicode_version":"6.0"},{"emoji":"🚦","aliases":["vertical_traffic_light"],"tags":["semaphore"],"category":"Travel & Places","description":"vertical traffic light","unicode_version":"6.0"},{"emoji":"🛑","aliases":["stop_sign"],"tags":[],"category":"Travel & Places","description":"stop sign","unicode_version":"9.0"},{"emoji":"🚧","aliases":["construction"],"tags":["wip"],"category":"Travel & Places","description":"construction","unicode_version":"6.0"},{"emoji":"⚓","aliases":["anchor"],"tags":["ship"],"category":"Travel & Places","description":"anchor","unicode_version":"4.1"},{"emoji":"⛵","aliases":["boat","sailboat"],"tags":[],"category":"Travel & Places","description":"sailboat","unicode_version":"5.2"},{"emoji":"🛶","aliases":["canoe"],"tags":[],"category":"Travel & Places","description":"canoe","unicode_version":"9.0"},{"emoji":"🚤","aliases":["speedboat"],"tags":["ship"],"category":"Travel & Places","description":"speedboat","unicode_version":"6.0"},{"emoji":"🛳️","aliases":["passenger_ship"],"tags":["cruise"],"category":"Travel & Places","description":"passenger ship","unicode_version":"7.0"},{"emoji":"⛴️","aliases":["ferry"],"tags":[],"category":"Travel & Places","description":"ferry","unicode_version":"5.2"},{"emoji":"🛥️","aliases":["motor_boat"],"tags":[],"category":"Travel & Places","description":"motor boat","unicode_version":"7.0"},{"emoji":"🚢","aliases":["ship"],"tags":[],"category":"Travel & Places","description":"ship","unicode_version":"6.0"},{"emoji":"✈️","aliases":["airplane"],"tags":["flight"],"category":"Travel & Places","description":"airplane","unicode_version":""},{"emoji":"🛩️","aliases":["small_airplane"],"tags":["flight"],"category":"Travel & Places","description":"small airplane","unicode_version":"7.0"},{"emoji":"🛫","aliases":["flight_departure"],"tags":[],"category":"Travel & Places","description":"airplane departure","unicode_version":"7.0"},{"emoji":"🛬","aliases":["flight_arrival"],"tags":[],"category":"Travel & Places","description":"airplane arrival","unicode_version":"7.0"},{"emoji":"🪂","aliases":["parachute"],"tags":[],"category":"Travel & Places","description":"parachute","unicode_version":"12.0"},{"emoji":"💺","aliases":["seat"],"tags":[],"category":"Travel & Places","description":"seat","unicode_version":"6.0"},{"emoji":"🚁","aliases":["helicopter"],"tags":[],"category":"Travel & Places","description":"helicopter","unicode_version":"6.0"},{"emoji":"🚟","aliases":["suspension_railway"],"tags":[],"category":"Travel & Places","description":"suspension railway","unicode_version":"6.0"},{"emoji":"🚠","aliases":["mountain_cableway"],"tags":[],"category":"Travel & Places","description":"mountain cableway","unicode_version":"6.0"},{"emoji":"🚡","aliases":["aerial_tramway"],"tags":[],"category":"Travel & Places","description":"aerial tramway","unicode_version":"6.0"},{"emoji":"🛰️","aliases":["artificial_satellite"],"tags":["orbit","space"],"category":"Travel & Places","description":"satellite","unicode_version":"7.0"},{"emoji":"🚀","aliases":["rocket"],"tags":["ship","launch"],"category":"Travel & Places","description":"rocket","unicode_version":"6.0"},{"emoji":"🛸","aliases":["flying_saucer"],"tags":["ufo"],"category":"Travel & Places","description":"flying saucer","unicode_version":"11.0"},{"emoji":"🛎️","aliases":["bellhop_bell"],"tags":[],"category":"Travel & Places","description":"bellhop bell","unicode_version":"7.0"},{"emoji":"🧳","aliases":["luggage"],"tags":[],"category":"Travel & Places","description":"luggage","unicode_version":"11.0"},{"emoji":"⌛","aliases":["hourglass"],"tags":["time"],"category":"Travel & Places","description":"hourglass done","unicode_version":""},{"emoji":"⏳","aliases":["hourglass_flowing_sand"],"tags":["time"],"category":"Travel & Places","description":"hourglass not done","unicode_version":"6.0"},{"emoji":"⌚","aliases":["watch"],"tags":["time"],"category":"Travel & Places","description":"watch","unicode_version":""},{"emoji":"⏰","aliases":["alarm_clock"],"tags":["morning"],"category":"Travel & Places","description":"alarm clock","unicode_version":"6.0"},{"emoji":"⏱️","aliases":["stopwatch"],"tags":[],"category":"Travel & Places","description":"stopwatch","unicode_version":"6.0"},{"emoji":"⏲️","aliases":["timer_clock"],"tags":[],"category":"Travel & Places","description":"timer clock","unicode_version":"6.0"},{"emoji":"🕰️","aliases":["mantelpiece_clock"],"tags":[],"category":"Travel & Places","description":"mantelpiece clock","unicode_version":"7.0"},{"emoji":"🕛","aliases":["clock12"],"tags":[],"category":"Travel & Places","description":"twelve o’clock","unicode_version":"6.0"},{"emoji":"🕧","aliases":["clock1230"],"tags":[],"category":"Travel & Places","description":"twelve-thirty","unicode_version":"6.0"},{"emoji":"🕐","aliases":["clock1"],"tags":[],"category":"Travel & Places","description":"one o’clock","unicode_version":"6.0"},{"emoji":"🕜","aliases":["clock130"],"tags":[],"category":"Travel & Places","description":"one-thirty","unicode_version":"6.0"},{"emoji":"🕑","aliases":["clock2"],"tags":[],"category":"Travel & Places","description":"two o’clock","unicode_version":"6.0"},{"emoji":"🕝","aliases":["clock230"],"tags":[],"category":"Travel & Places","description":"two-thirty","unicode_version":"6.0"},{"emoji":"🕒","aliases":["clock3"],"tags":[],"category":"Travel & Places","description":"three o’clock","unicode_version":"6.0"},{"emoji":"🕞","aliases":["clock330"],"tags":[],"category":"Travel & Places","description":"three-thirty","unicode_version":"6.0"},{"emoji":"🕓","aliases":["clock4"],"tags":[],"category":"Travel & Places","description":"four o’clock","unicode_version":"6.0"},{"emoji":"🕟","aliases":["clock430"],"tags":[],"category":"Travel & Places","description":"four-thirty","unicode_version":"6.0"},{"emoji":"🕔","aliases":["clock5"],"tags":[],"category":"Travel & Places","description":"five o’clock","unicode_version":"6.0"},{"emoji":"🕠","aliases":["clock530"],"tags":[],"category":"Travel & Places","description":"five-thirty","unicode_version":"6.0"},{"emoji":"🕕","aliases":["clock6"],"tags":[],"category":"Travel & Places","description":"six o’clock","unicode_version":"6.0"},{"emoji":"🕡","aliases":["clock630"],"tags":[],"category":"Travel & Places","description":"six-thirty","unicode_version":"6.0"},{"emoji":"🕖","aliases":["clock7"],"tags":[],"category":"Travel & Places","description":"seven o’clock","unicode_version":"6.0"},{"emoji":"🕢","aliases":["clock730"],"tags":[],"category":"Travel & Places","description":"seven-thirty","unicode_version":"6.0"},{"emoji":"🕗","aliases":["clock8"],"tags":[],"category":"Travel & Places","description":"eight o’clock","unicode_version":"6.0"},{"emoji":"🕣","aliases":["clock830"],"tags":[],"category":"Travel & Places","description":"eight-thirty","unicode_version":"6.0"},{"emoji":"🕘","aliases":["clock9"],"tags":[],"category":"Travel & Places","description":"nine o’clock","unicode_version":"6.0"},{"emoji":"🕤","aliases":["clock930"],"tags":[],"category":"Travel & Places","description":"nine-thirty","unicode_version":"6.0"},{"emoji":"🕙","aliases":["clock10"],"tags":[],"category":"Travel & Places","description":"ten o’clock","unicode_version":"6.0"},{"emoji":"🕥","aliases":["clock1030"],"tags":[],"category":"Travel & Places","description":"ten-thirty","unicode_version":"6.0"},{"emoji":"🕚","aliases":["clock11"],"tags":[],"category":"Travel & Places","description":"eleven o’clock","unicode_version":"6.0"},{"emoji":"🕦","aliases":["clock1130"],"tags":[],"category":"Travel & Places","description":"eleven-thirty","unicode_version":"6.0"},{"emoji":"🌑","aliases":["new_moon"],"tags":[],"category":"Travel & Places","description":"new moon","unicode_version":"6.0"},{"emoji":"🌒","aliases":["waxing_crescent_moon"],"tags":[],"category":"Travel & Places","description":"waxing crescent moon","unicode_version":"6.0"},{"emoji":"🌓","aliases":["first_quarter_moon"],"tags":[],"category":"Travel & Places","description":"first quarter moon","unicode_version":"6.0"},{"emoji":"🌔","aliases":["moon","waxing_gibbous_moon"],"tags":[],"category":"Travel & Places","description":"waxing gibbous moon","unicode_version":"6.0"},{"emoji":"🌕","aliases":["full_moon"],"tags":[],"category":"Travel & Places","description":"full moon","unicode_version":"6.0"},{"emoji":"🌖","aliases":["waning_gibbous_moon"],"tags":[],"category":"Travel & Places","description":"waning gibbous moon","unicode_version":"6.0"},{"emoji":"🌗","aliases":["last_quarter_moon"],"tags":[],"category":"Travel & Places","description":"last quarter moon","unicode_version":"6.0"},{"emoji":"🌘","aliases":["waning_crescent_moon"],"tags":[],"category":"Travel & Places","description":"waning crescent moon","unicode_version":"6.0"},{"emoji":"🌙","aliases":["crescent_moon"],"tags":["night"],"category":"Travel & Places","description":"crescent moon","unicode_version":"6.0"},{"emoji":"🌚","aliases":["new_moon_with_face"],"tags":[],"category":"Travel & Places","description":"new moon face","unicode_version":"6.0"},{"emoji":"🌛","aliases":["first_quarter_moon_with_face"],"tags":[],"category":"Travel & Places","description":"first quarter moon face","unicode_version":"6.0"},{"emoji":"🌜","aliases":["last_quarter_moon_with_face"],"tags":[],"category":"Travel & Places","description":"last quarter moon face","unicode_version":"6.0"},{"emoji":"🌡️","aliases":["thermometer"],"tags":[],"category":"Travel & Places","description":"thermometer","unicode_version":"7.0"},{"emoji":"☀️","aliases":["sunny"],"tags":["weather"],"category":"Travel & Places","description":"sun","unicode_version":""},{"emoji":"🌝","aliases":["full_moon_with_face"],"tags":[],"category":"Travel & Places","description":"full moon face","unicode_version":"6.0"},{"emoji":"🌞","aliases":["sun_with_face"],"tags":["summer"],"category":"Travel & Places","description":"sun with face","unicode_version":"6.0"},{"emoji":"🪐","aliases":["ringed_planet"],"tags":[],"category":"Travel & Places","description":"ringed planet","unicode_version":"12.0"},{"emoji":"⭐","aliases":["star"],"tags":[],"category":"Travel & Places","description":"star","unicode_version":"5.1"},{"emoji":"🌟","aliases":["star2"],"tags":[],"category":"Travel & Places","description":"glowing star","unicode_version":"6.0"},{"emoji":"🌠","aliases":["stars"],"tags":[],"category":"Travel & Places","description":"shooting star","unicode_version":"6.0"},{"emoji":"🌌","aliases":["milky_way"],"tags":[],"category":"Travel & Places","description":"milky way","unicode_version":"6.0"},{"emoji":"☁️","aliases":["cloud"],"tags":[],"category":"Travel & Places","description":"cloud","unicode_version":""},{"emoji":"⛅","aliases":["partly_sunny"],"tags":["weather","cloud"],"category":"Travel & Places","description":"sun behind cloud","unicode_version":"5.2"},{"emoji":"⛈️","aliases":["cloud_with_lightning_and_rain"],"tags":[],"category":"Travel & Places","description":"cloud with lightning and rain","unicode_version":"5.2"},{"emoji":"🌤️","aliases":["sun_behind_small_cloud"],"tags":[],"category":"Travel & Places","description":"sun behind small cloud","unicode_version":"7.0"},{"emoji":"🌥️","aliases":["sun_behind_large_cloud"],"tags":[],"category":"Travel & Places","description":"sun behind large cloud","unicode_version":"7.0"},{"emoji":"🌦️","aliases":["sun_behind_rain_cloud"],"tags":[],"category":"Travel & Places","description":"sun behind rain cloud","unicode_version":"7.0"},{"emoji":"🌧️","aliases":["cloud_with_rain"],"tags":[],"category":"Travel & Places","description":"cloud with rain","unicode_version":"7.0"},{"emoji":"🌨️","aliases":["cloud_with_snow"],"tags":[],"category":"Travel & Places","description":"cloud with snow","unicode_version":"7.0"},{"emoji":"🌩️","aliases":["cloud_with_lightning"],"tags":[],"category":"Travel & Places","description":"cloud with lightning","unicode_version":"7.0"},{"emoji":"🌪️","aliases":["tornado"],"tags":[],"category":"Travel & Places","description":"tornado","unicode_version":"7.0"},{"emoji":"🌫️","aliases":["fog"],"tags":[],"category":"Travel & Places","description":"fog","unicode_version":"7.0"},{"emoji":"🌬️","aliases":["wind_face"],"tags":[],"category":"Travel & Places","description":"wind face","unicode_version":"7.0"},{"emoji":"🌀","aliases":["cyclone"],"tags":["swirl"],"category":"Travel & Places","description":"cyclone","unicode_version":"6.0"},{"emoji":"🌈","aliases":["rainbow"],"tags":[],"category":"Travel & Places","description":"rainbow","unicode_version":"6.0"},{"emoji":"🌂","aliases":["closed_umbrella"],"tags":["weather","rain"],"category":"Travel & Places","description":"closed umbrella","unicode_version":"6.0"},{"emoji":"☂️","aliases":["open_umbrella"],"tags":[],"category":"Travel & Places","description":"umbrella","unicode_version":""},{"emoji":"☔","aliases":["umbrella"],"tags":["rain","weather"],"category":"Travel & Places","description":"umbrella with rain drops","unicode_version":"4.0"},{"emoji":"⛱️","aliases":["parasol_on_ground"],"tags":["beach_umbrella"],"category":"Travel & Places","description":"umbrella on ground","unicode_version":"5.2"},{"emoji":"⚡","aliases":["zap"],"tags":["lightning","thunder"],"category":"Travel & Places","description":"high voltage","unicode_version":"4.0"},{"emoji":"❄️","aliases":["snowflake"],"tags":["winter","cold","weather"],"category":"Travel & Places","description":"snowflake","unicode_version":""},{"emoji":"☃️","aliases":["snowman_with_snow"],"tags":["winter","christmas"],"category":"Travel & Places","description":"snowman","unicode_version":""},{"emoji":"⛄","aliases":["snowman"],"tags":["winter"],"category":"Travel & Places","description":"snowman without snow","unicode_version":"5.2"},{"emoji":"☄️","aliases":["comet"],"tags":[],"category":"Travel & Places","description":"comet","unicode_version":""},{"emoji":"🔥","aliases":["fire"],"tags":["burn"],"category":"Travel & Places","description":"fire","unicode_version":"6.0"},{"emoji":"💧","aliases":["droplet"],"tags":["water"],"category":"Travel & Places","description":"droplet","unicode_version":"6.0"},{"emoji":"🌊","aliases":["ocean"],"tags":["sea"],"category":"Travel & Places","description":"water wave","unicode_version":"6.0"},{"emoji":"🎃","aliases":["jack_o_lantern"],"tags":["halloween"],"category":"Activities","description":"jack-o-lantern","unicode_version":"6.0"},{"emoji":"🎄","aliases":["christmas_tree"],"tags":[],"category":"Activities","description":"Christmas tree","unicode_version":"6.0"},{"emoji":"🎆","aliases":["fireworks"],"tags":["festival","celebration"],"category":"Activities","description":"fireworks","unicode_version":"6.0"},{"emoji":"🎇","aliases":["sparkler"],"tags":[],"category":"Activities","description":"sparkler","unicode_version":"6.0"},{"emoji":"🧨","aliases":["firecracker"],"tags":[],"category":"Activities","description":"firecracker","unicode_version":"11.0"},{"emoji":"✨","aliases":["sparkles"],"tags":["shiny"],"category":"Activities","description":"sparkles","unicode_version":"6.0"},{"emoji":"🎈","aliases":["balloon"],"tags":["party","birthday"],"category":"Activities","description":"balloon","unicode_version":"6.0"},{"emoji":"🎉","aliases":["tada"],"tags":["hooray","party"],"category":"Activities","description":"party popper","unicode_version":"6.0"},{"emoji":"🎊","aliases":["confetti_ball"],"tags":[],"category":"Activities","description":"confetti ball","unicode_version":"6.0"},{"emoji":"🎋","aliases":["tanabata_tree"],"tags":[],"category":"Activities","description":"tanabata tree","unicode_version":"6.0"},{"emoji":"🎍","aliases":["bamboo"],"tags":[],"category":"Activities","description":"pine decoration","unicode_version":"6.0"},{"emoji":"🎎","aliases":["dolls"],"tags":[],"category":"Activities","description":"Japanese dolls","unicode_version":"6.0"},{"emoji":"🎏","aliases":["flags"],"tags":[],"category":"Activities","description":"carp streamer","unicode_version":"6.0"},{"emoji":"🎐","aliases":["wind_chime"],"tags":[],"category":"Activities","description":"wind chime","unicode_version":"6.0"},{"emoji":"🎑","aliases":["rice_scene"],"tags":[],"category":"Activities","description":"moon viewing ceremony","unicode_version":"6.0"},{"emoji":"🧧","aliases":["red_envelope"],"tags":[],"category":"Activities","description":"red envelope","unicode_version":"11.0"},{"emoji":"🎀","aliases":["ribbon"],"tags":[],"category":"Activities","description":"ribbon","unicode_version":"6.0"},{"emoji":"🎁","aliases":["gift"],"tags":["present","birthday","christmas"],"category":"Activities","description":"wrapped gift","unicode_version":"6.0"},{"emoji":"🎗️","aliases":["reminder_ribbon"],"tags":[],"category":"Activities","description":"reminder ribbon","unicode_version":"7.0"},{"emoji":"🎟️","aliases":["tickets"],"tags":[],"category":"Activities","description":"admission tickets","unicode_version":"7.0"},{"emoji":"🎫","aliases":["ticket"],"tags":[],"category":"Activities","description":"ticket","unicode_version":"6.0"},{"emoji":"🎖️","aliases":["medal_military"],"tags":[],"category":"Activities","description":"military medal","unicode_version":"7.0"},{"emoji":"🏆","aliases":["trophy"],"tags":["award","contest","winner"],"category":"Activities","description":"trophy","unicode_version":"6.0"},{"emoji":"🏅","aliases":["medal_sports"],"tags":["gold","winner"],"category":"Activities","description":"sports medal","unicode_version":"7.0"},{"emoji":"🥇","aliases":["1st_place_medal"],"tags":["gold"],"category":"Activities","description":"1st place medal","unicode_version":"9.0"},{"emoji":"🥈","aliases":["2nd_place_medal"],"tags":["silver"],"category":"Activities","description":"2nd place medal","unicode_version":"9.0"},{"emoji":"🥉","aliases":["3rd_place_medal"],"tags":["bronze"],"category":"Activities","description":"3rd place medal","unicode_version":"9.0"},{"emoji":"⚽","aliases":["soccer"],"tags":["sports"],"category":"Activities","description":"soccer ball","unicode_version":"5.2"},{"emoji":"⚾","aliases":["baseball"],"tags":["sports"],"category":"Activities","description":"baseball","unicode_version":"5.2"},{"emoji":"🥎","aliases":["softball"],"tags":[],"category":"Activities","description":"softball","unicode_version":"11.0"},{"emoji":"🏀","aliases":["basketball"],"tags":["sports"],"category":"Activities","description":"basketball","unicode_version":"6.0"},{"emoji":"🏐","aliases":["volleyball"],"tags":[],"category":"Activities","description":"volleyball","unicode_version":"8.0"},{"emoji":"🏈","aliases":["football"],"tags":["sports"],"category":"Activities","description":"american football","unicode_version":"6.0"},{"emoji":"🏉","aliases":["rugby_football"],"tags":[],"category":"Activities","description":"rugby football","unicode_version":"6.0"},{"emoji":"🎾","aliases":["tennis"],"tags":["sports"],"category":"Activities","description":"tennis","unicode_version":"6.0"},{"emoji":"🥏","aliases":["flying_disc"],"tags":[],"category":"Activities","description":"flying disc","unicode_version":"11.0"},{"emoji":"🎳","aliases":["bowling"],"tags":[],"category":"Activities","description":"bowling","unicode_version":"6.0"},{"emoji":"🏏","aliases":["cricket_game"],"tags":[],"category":"Activities","description":"cricket game","unicode_version":"8.0"},{"emoji":"🏑","aliases":["field_hockey"],"tags":[],"category":"Activities","description":"field hockey","unicode_version":"8.0"},{"emoji":"🏒","aliases":["ice_hockey"],"tags":[],"category":"Activities","description":"ice hockey","unicode_version":"8.0"},{"emoji":"🥍","aliases":["lacrosse"],"tags":[],"category":"Activities","description":"lacrosse","unicode_version":"11.0"},{"emoji":"🏓","aliases":["ping_pong"],"tags":[],"category":"Activities","description":"ping pong","unicode_version":"8.0"},{"emoji":"🏸","aliases":["badminton"],"tags":[],"category":"Activities","description":"badminton","unicode_version":"8.0"},{"emoji":"🥊","aliases":["boxing_glove"],"tags":[],"category":"Activities","description":"boxing glove","unicode_version":"9.0"},{"emoji":"🥋","aliases":["martial_arts_uniform"],"tags":[],"category":"Activities","description":"martial arts uniform","unicode_version":"9.0"},{"emoji":"🥅","aliases":["goal_net"],"tags":[],"category":"Activities","description":"goal net","unicode_version":"9.0"},{"emoji":"⛳","aliases":["golf"],"tags":[],"category":"Activities","description":"flag in hole","unicode_version":"5.2"},{"emoji":"⛸️","aliases":["ice_skate"],"tags":["skating"],"category":"Activities","description":"ice skate","unicode_version":"5.2"},{"emoji":"🎣","aliases":["fishing_pole_and_fish"],"tags":[],"category":"Activities","description":"fishing pole","unicode_version":"6.0"},{"emoji":"🤿","aliases":["diving_mask"],"tags":[],"category":"Activities","description":"diving mask","unicode_version":"12.0"},{"emoji":"🎽","aliases":["running_shirt_with_sash"],"tags":["marathon"],"category":"Activities","description":"running shirt","unicode_version":"6.0"},{"emoji":"🎿","aliases":["ski"],"tags":[],"category":"Activities","description":"skis","unicode_version":"6.0"},{"emoji":"🛷","aliases":["sled"],"tags":[],"category":"Activities","description":"sled","unicode_version":"11.0"},{"emoji":"🥌","aliases":["curling_stone"],"tags":[],"category":"Activities","description":"curling stone","unicode_version":"11.0"},{"emoji":"🎯","aliases":["dart"],"tags":["target"],"category":"Activities","description":"bullseye","unicode_version":"6.0"},{"emoji":"🪀","aliases":["yo_yo"],"tags":[],"category":"Activities","description":"yo-yo","unicode_version":"12.0"},{"emoji":"🪁","aliases":["kite"],"tags":[],"category":"Activities","description":"kite","unicode_version":"12.0"},{"emoji":"🎱","aliases":["8ball"],"tags":["pool","billiards"],"category":"Activities","description":"pool 8 ball","unicode_version":"6.0"},{"emoji":"🔮","aliases":["crystal_ball"],"tags":["fortune"],"category":"Activities","description":"crystal ball","unicode_version":"6.0"},{"emoji":"🪄","aliases":["magic_wand"],"tags":[],"category":"Activities","description":"magic wand","unicode_version":"13.0"},{"emoji":"🧿","aliases":["nazar_amulet"],"tags":[],"category":"Activities","description":"nazar amulet","unicode_version":"11.0"},{"emoji":"🎮","aliases":["video_game"],"tags":["play","controller","console"],"category":"Activities","description":"video game","unicode_version":"6.0"},{"emoji":"🕹️","aliases":["joystick"],"tags":[],"category":"Activities","description":"joystick","unicode_version":"7.0"},{"emoji":"🎰","aliases":["slot_machine"],"tags":[],"category":"Activities","description":"slot machine","unicode_version":"6.0"},{"emoji":"🎲","aliases":["game_die"],"tags":["dice","gambling"],"category":"Activities","description":"game die","unicode_version":"6.0"},{"emoji":"🧩","aliases":["jigsaw"],"tags":[],"category":"Activities","description":"puzzle piece","unicode_version":"11.0"},{"emoji":"🧸","aliases":["teddy_bear"],"tags":[],"category":"Activities","description":"teddy bear","unicode_version":"11.0"},{"emoji":"🪅","aliases":["pinata"],"tags":[],"category":"Activities","description":"piñata","unicode_version":"13.0"},{"emoji":"🪆","aliases":["nesting_dolls"],"tags":[],"category":"Activities","description":"nesting dolls","unicode_version":"13.0"},{"emoji":"♠️","aliases":["spades"],"tags":[],"category":"Activities","description":"spade suit","unicode_version":""},{"emoji":"♥️","aliases":["hearts"],"tags":[],"category":"Activities","description":"heart suit","unicode_version":""},{"emoji":"♦️","aliases":["diamonds"],"tags":[],"category":"Activities","description":"diamond suit","unicode_version":""},{"emoji":"♣️","aliases":["clubs"],"tags":[],"category":"Activities","description":"club suit","unicode_version":""},{"emoji":"♟️","aliases":["chess_pawn"],"tags":[],"category":"Activities","description":"chess pawn","unicode_version":"11.0"},{"emoji":"🃏","aliases":["black_joker"],"tags":[],"category":"Activities","description":"joker","unicode_version":"6.0"},{"emoji":"🀄","aliases":["mahjong"],"tags":[],"category":"Activities","description":"mahjong red dragon","unicode_version":""},{"emoji":"🎴","aliases":["flower_playing_cards"],"tags":[],"category":"Activities","description":"flower playing cards","unicode_version":"6.0"},{"emoji":"🎭","aliases":["performing_arts"],"tags":["theater","drama"],"category":"Activities","description":"performing arts","unicode_version":"6.0"},{"emoji":"🖼️","aliases":["framed_picture"],"tags":[],"category":"Activities","description":"framed picture","unicode_version":"7.0"},{"emoji":"🎨","aliases":["art"],"tags":["design","paint"],"category":"Activities","description":"artist palette","unicode_version":"6.0"},{"emoji":"🧵","aliases":["thread"],"tags":[],"category":"Activities","description":"thread","unicode_version":"11.0"},{"emoji":"🪡","aliases":["sewing_needle"],"tags":[],"category":"Activities","description":"sewing needle","unicode_version":"13.0"},{"emoji":"🧶","aliases":["yarn"],"tags":[],"category":"Activities","description":"yarn","unicode_version":"11.0"},{"emoji":"🪢","aliases":["knot"],"tags":[],"category":"Activities","description":"knot","unicode_version":"13.0"},{"emoji":"👓","aliases":["eyeglasses"],"tags":["glasses"],"category":"Objects","description":"glasses","unicode_version":"6.0"},{"emoji":"🕶️","aliases":["dark_sunglasses"],"tags":[],"category":"Objects","description":"sunglasses","unicode_version":"7.0"},{"emoji":"🥽","aliases":["goggles"],"tags":[],"category":"Objects","description":"goggles","unicode_version":"11.0"},{"emoji":"🥼","aliases":["lab_coat"],"tags":[],"category":"Objects","description":"lab coat","unicode_version":"11.0"},{"emoji":"🦺","aliases":["safety_vest"],"tags":[],"category":"Objects","description":"safety vest","unicode_version":"12.0"},{"emoji":"👔","aliases":["necktie"],"tags":["shirt","formal"],"category":"Objects","description":"necktie","unicode_version":"6.0"},{"emoji":"👕","aliases":["shirt","tshirt"],"tags":[],"category":"Objects","description":"t-shirt","unicode_version":"6.0"},{"emoji":"👖","aliases":["jeans"],"tags":["pants"],"category":"Objects","description":"jeans","unicode_version":"6.0"},{"emoji":"🧣","aliases":["scarf"],"tags":[],"category":"Objects","description":"scarf","unicode_version":"11.0"},{"emoji":"🧤","aliases":["gloves"],"tags":[],"category":"Objects","description":"gloves","unicode_version":"11.0"},{"emoji":"🧥","aliases":["coat"],"tags":[],"category":"Objects","description":"coat","unicode_version":"11.0"},{"emoji":"🧦","aliases":["socks"],"tags":[],"category":"Objects","description":"socks","unicode_version":"11.0"},{"emoji":"👗","aliases":["dress"],"tags":[],"category":"Objects","description":"dress","unicode_version":"6.0"},{"emoji":"👘","aliases":["kimono"],"tags":[],"category":"Objects","description":"kimono","unicode_version":"6.0"},{"emoji":"🥻","aliases":["sari"],"tags":[],"category":"Objects","description":"sari","unicode_version":"12.0"},{"emoji":"🩱","aliases":["one_piece_swimsuit"],"tags":[],"category":"Objects","description":"one-piece swimsuit","unicode_version":"12.0"},{"emoji":"🩲","aliases":["swim_brief"],"tags":[],"category":"Objects","description":"briefs","unicode_version":"12.0"},{"emoji":"🩳","aliases":["shorts"],"tags":[],"category":"Objects","description":"shorts","unicode_version":"12.0"},{"emoji":"👙","aliases":["bikini"],"tags":["beach"],"category":"Objects","description":"bikini","unicode_version":"6.0"},{"emoji":"👚","aliases":["womans_clothes"],"tags":[],"category":"Objects","description":"woman’s clothes","unicode_version":"6.0"},{"emoji":"👛","aliases":["purse"],"tags":[],"category":"Objects","description":"purse","unicode_version":"6.0"},{"emoji":"👜","aliases":["handbag"],"tags":["bag"],"category":"Objects","description":"handbag","unicode_version":"6.0"},{"emoji":"👝","aliases":["pouch"],"tags":["bag"],"category":"Objects","description":"clutch bag","unicode_version":"6.0"},{"emoji":"🛍️","aliases":["shopping"],"tags":["bags"],"category":"Objects","description":"shopping bags","unicode_version":"7.0"},{"emoji":"🎒","aliases":["school_satchel"],"tags":[],"category":"Objects","description":"backpack","unicode_version":"6.0"},{"emoji":"🩴","aliases":["thong_sandal"],"tags":[],"category":"Objects","description":"thong sandal","unicode_version":"13.0"},{"emoji":"👞","aliases":["mans_shoe","shoe"],"tags":[],"category":"Objects","description":"man’s shoe","unicode_version":"6.0"},{"emoji":"👟","aliases":["athletic_shoe"],"tags":["sneaker","sport","running"],"category":"Objects","description":"running shoe","unicode_version":"6.0"},{"emoji":"🥾","aliases":["hiking_boot"],"tags":[],"category":"Objects","description":"hiking boot","unicode_version":"11.0"},{"emoji":"🥿","aliases":["flat_shoe"],"tags":[],"category":"Objects","description":"flat shoe","unicode_version":"11.0"},{"emoji":"👠","aliases":["high_heel"],"tags":["shoe"],"category":"Objects","description":"high-heeled shoe","unicode_version":"6.0"},{"emoji":"👡","aliases":["sandal"],"tags":["shoe"],"category":"Objects","description":"woman’s sandal","unicode_version":"6.0"},{"emoji":"🩰","aliases":["ballet_shoes"],"tags":[],"category":"Objects","description":"ballet shoes","unicode_version":"12.0"},{"emoji":"👢","aliases":["boot"],"tags":[],"category":"Objects","description":"woman’s boot","unicode_version":"6.0"},{"emoji":"👑","aliases":["crown"],"tags":["king","queen","royal"],"category":"Objects","description":"crown","unicode_version":"6.0"},{"emoji":"👒","aliases":["womans_hat"],"tags":[],"category":"Objects","description":"woman’s hat","unicode_version":"6.0"},{"emoji":"🎩","aliases":["tophat"],"tags":["hat","classy"],"category":"Objects","description":"top hat","unicode_version":"6.0"},{"emoji":"🎓","aliases":["mortar_board"],"tags":["education","college","university","graduation"],"category":"Objects","description":"graduation cap","unicode_version":"6.0"},{"emoji":"🧢","aliases":["billed_cap"],"tags":[],"category":"Objects","description":"billed cap","unicode_version":"11.0"},{"emoji":"🪖","aliases":["military_helmet"],"tags":[],"category":"Objects","description":"military helmet","unicode_version":"13.0"},{"emoji":"⛑️","aliases":["rescue_worker_helmet"],"tags":[],"category":"Objects","description":"rescue worker’s helmet","unicode_version":"5.2"},{"emoji":"📿","aliases":["prayer_beads"],"tags":[],"category":"Objects","description":"prayer beads","unicode_version":"8.0"},{"emoji":"💄","aliases":["lipstick"],"tags":["makeup"],"category":"Objects","description":"lipstick","unicode_version":"6.0"},{"emoji":"💍","aliases":["ring"],"tags":["wedding","marriage","engaged"],"category":"Objects","description":"ring","unicode_version":"6.0"},{"emoji":"💎","aliases":["gem"],"tags":["diamond"],"category":"Objects","description":"gem stone","unicode_version":"6.0"},{"emoji":"🔇","aliases":["mute"],"tags":["sound","volume"],"category":"Objects","description":"muted speaker","unicode_version":"6.0"},{"emoji":"🔈","aliases":["speaker"],"tags":[],"category":"Objects","description":"speaker low volume","unicode_version":"6.0"},{"emoji":"🔉","aliases":["sound"],"tags":["volume"],"category":"Objects","description":"speaker medium volume","unicode_version":"6.0"},{"emoji":"🔊","aliases":["loud_sound"],"tags":["volume"],"category":"Objects","description":"speaker high volume","unicode_version":"6.0"},{"emoji":"📢","aliases":["loudspeaker"],"tags":["announcement"],"category":"Objects","description":"loudspeaker","unicode_version":"6.0"},{"emoji":"📣","aliases":["mega"],"tags":[],"category":"Objects","description":"megaphone","unicode_version":"6.0"},{"emoji":"📯","aliases":["postal_horn"],"tags":[],"category":"Objects","description":"postal horn","unicode_version":"6.0"},{"emoji":"🔔","aliases":["bell"],"tags":["sound","notification"],"category":"Objects","description":"bell","unicode_version":"6.0"},{"emoji":"🔕","aliases":["no_bell"],"tags":["volume","off"],"category":"Objects","description":"bell with slash","unicode_version":"6.0"},{"emoji":"🎼","aliases":["musical_score"],"tags":[],"category":"Objects","description":"musical score","unicode_version":"6.0"},{"emoji":"🎵","aliases":["musical_note"],"tags":[],"category":"Objects","description":"musical note","unicode_version":"6.0"},{"emoji":"🎶","aliases":["notes"],"tags":["music"],"category":"Objects","description":"musical notes","unicode_version":"6.0"},{"emoji":"🎙️","aliases":["studio_microphone"],"tags":["podcast"],"category":"Objects","description":"studio microphone","unicode_version":"7.0"},{"emoji":"🎚️","aliases":["level_slider"],"tags":[],"category":"Objects","description":"level slider","unicode_version":"7.0"},{"emoji":"🎛️","aliases":["control_knobs"],"tags":[],"category":"Objects","description":"control knobs","unicode_version":"7.0"},{"emoji":"🎤","aliases":["microphone"],"tags":["sing"],"category":"Objects","description":"microphone","unicode_version":"6.0"},{"emoji":"🎧","aliases":["headphones"],"tags":["music","earphones"],"category":"Objects","description":"headphone","unicode_version":"6.0"},{"emoji":"📻","aliases":["radio"],"tags":["podcast"],"category":"Objects","description":"radio","unicode_version":"6.0"},{"emoji":"🎷","aliases":["saxophone"],"tags":[],"category":"Objects","description":"saxophone","unicode_version":"6.0"},{"emoji":"🪗","aliases":["accordion"],"tags":[],"category":"Objects","description":"accordion","unicode_version":"13.0"},{"emoji":"🎸","aliases":["guitar"],"tags":["rock"],"category":"Objects","description":"guitar","unicode_version":"6.0"},{"emoji":"🎹","aliases":["musical_keyboard"],"tags":["piano"],"category":"Objects","description":"musical keyboard","unicode_version":"6.0"},{"emoji":"🎺","aliases":["trumpet"],"tags":[],"category":"Objects","description":"trumpet","unicode_version":"6.0"},{"emoji":"🎻","aliases":["violin"],"tags":[],"category":"Objects","description":"violin","unicode_version":"6.0"},{"emoji":"🪕","aliases":["banjo"],"tags":[],"category":"Objects","description":"banjo","unicode_version":"12.0"},{"emoji":"🥁","aliases":["drum"],"tags":[],"category":"Objects","description":"drum","unicode_version":""},{"emoji":"🪘","aliases":["long_drum"],"tags":[],"category":"Objects","description":"long drum","unicode_version":"13.0"},{"emoji":"📱","aliases":["iphone"],"tags":["smartphone","mobile"],"category":"Objects","description":"mobile phone","unicode_version":"6.0"},{"emoji":"📲","aliases":["calling"],"tags":["call","incoming"],"category":"Objects","description":"mobile phone with arrow","unicode_version":"6.0"},{"emoji":"☎️","aliases":["phone","telephone"],"tags":[],"category":"Objects","description":"telephone","unicode_version":""},{"emoji":"📞","aliases":["telephone_receiver"],"tags":["phone","call"],"category":"Objects","description":"telephone receiver","unicode_version":"6.0"},{"emoji":"📟","aliases":["pager"],"tags":[],"category":"Objects","description":"pager","unicode_version":"6.0"},{"emoji":"📠","aliases":["fax"],"tags":[],"category":"Objects","description":"fax machine","unicode_version":"6.0"},{"emoji":"🔋","aliases":["battery"],"tags":["power"],"category":"Objects","description":"battery","unicode_version":"6.0"},{"emoji":"🔌","aliases":["electric_plug"],"tags":[],"category":"Objects","description":"electric plug","unicode_version":"6.0"},{"emoji":"💻","aliases":["computer"],"tags":["desktop","screen"],"category":"Objects","description":"laptop","unicode_version":"6.0"},{"emoji":"🖥️","aliases":["desktop_computer"],"tags":[],"category":"Objects","description":"desktop computer","unicode_version":"7.0"},{"emoji":"🖨️","aliases":["printer"],"tags":[],"category":"Objects","description":"printer","unicode_version":"7.0"},{"emoji":"⌨️","aliases":["keyboard"],"tags":[],"category":"Objects","description":"keyboard","unicode_version":""},{"emoji":"🖱️","aliases":["computer_mouse"],"tags":[],"category":"Objects","description":"computer mouse","unicode_version":"7.0"},{"emoji":"🖲️","aliases":["trackball"],"tags":[],"category":"Objects","description":"trackball","unicode_version":"7.0"},{"emoji":"💽","aliases":["minidisc"],"tags":[],"category":"Objects","description":"computer disk","unicode_version":"6.0"},{"emoji":"💾","aliases":["floppy_disk"],"tags":["save"],"category":"Objects","description":"floppy disk","unicode_version":"6.0"},{"emoji":"💿","aliases":["cd"],"tags":[],"category":"Objects","description":"optical disk","unicode_version":"6.0"},{"emoji":"📀","aliases":["dvd"],"tags":[],"category":"Objects","description":"dvd","unicode_version":"6.0"},{"emoji":"🧮","aliases":["abacus"],"tags":[],"category":"Objects","description":"abacus","unicode_version":"11.0"},{"emoji":"🎥","aliases":["movie_camera"],"tags":["film","video"],"category":"Objects","description":"movie camera","unicode_version":"6.0"},{"emoji":"🎞️","aliases":["film_strip"],"tags":[],"category":"Objects","description":"film frames","unicode_version":"7.0"},{"emoji":"📽️","aliases":["film_projector"],"tags":[],"category":"Objects","description":"film projector","unicode_version":"7.0"},{"emoji":"🎬","aliases":["clapper"],"tags":["film"],"category":"Objects","description":"clapper board","unicode_version":"6.0"},{"emoji":"📺","aliases":["tv"],"tags":[],"category":"Objects","description":"television","unicode_version":"6.0"},{"emoji":"📷","aliases":["camera"],"tags":["photo"],"category":"Objects","description":"camera","unicode_version":"6.0"},{"emoji":"📸","aliases":["camera_flash"],"tags":["photo"],"category":"Objects","description":"camera with flash","unicode_version":"7.0"},{"emoji":"📹","aliases":["video_camera"],"tags":[],"category":"Objects","description":"video camera","unicode_version":"6.0"},{"emoji":"📼","aliases":["vhs"],"tags":[],"category":"Objects","description":"videocassette","unicode_version":"6.0"},{"emoji":"🔍","aliases":["mag"],"tags":["search","zoom"],"category":"Objects","description":"magnifying glass tilted left","unicode_version":"6.0"},{"emoji":"🔎","aliases":["mag_right"],"tags":[],"category":"Objects","description":"magnifying glass tilted right","unicode_version":"6.0"},{"emoji":"🕯️","aliases":["candle"],"tags":[],"category":"Objects","description":"candle","unicode_version":"7.0"},{"emoji":"💡","aliases":["bulb"],"tags":["idea","light"],"category":"Objects","description":"light bulb","unicode_version":"6.0"},{"emoji":"🔦","aliases":["flashlight"],"tags":[],"category":"Objects","description":"flashlight","unicode_version":"6.0"},{"emoji":"🏮","aliases":["izakaya_lantern","lantern"],"tags":[],"category":"Objects","description":"red paper lantern","unicode_version":"6.0"},{"emoji":"🪔","aliases":["diya_lamp"],"tags":[],"category":"Objects","description":"diya lamp","unicode_version":"12.0"},{"emoji":"📔","aliases":["notebook_with_decorative_cover"],"tags":[],"category":"Objects","description":"notebook with decorative cover","unicode_version":"6.0"},{"emoji":"📕","aliases":["closed_book"],"tags":[],"category":"Objects","description":"closed book","unicode_version":"6.0"},{"emoji":"📖","aliases":["book","open_book"],"tags":[],"category":"Objects","description":"open book","unicode_version":"6.0"},{"emoji":"📗","aliases":["green_book"],"tags":[],"category":"Objects","description":"green book","unicode_version":"6.0"},{"emoji":"📘","aliases":["blue_book"],"tags":[],"category":"Objects","description":"blue book","unicode_version":"6.0"},{"emoji":"📙","aliases":["orange_book"],"tags":[],"category":"Objects","description":"orange book","unicode_version":"6.0"},{"emoji":"📚","aliases":["books"],"tags":["library"],"category":"Objects","description":"books","unicode_version":"6.0"},{"emoji":"📓","aliases":["notebook"],"tags":[],"category":"Objects","description":"notebook","unicode_version":"6.0"},{"emoji":"📒","aliases":["ledger"],"tags":[],"category":"Objects","description":"ledger","unicode_version":"6.0"},{"emoji":"📃","aliases":["page_with_curl"],"tags":[],"category":"Objects","description":"page with curl","unicode_version":"6.0"},{"emoji":"📜","aliases":["scroll"],"tags":["document"],"category":"Objects","description":"scroll","unicode_version":"6.0"},{"emoji":"📄","aliases":["page_facing_up"],"tags":["document"],"category":"Objects","description":"page facing up","unicode_version":"6.0"},{"emoji":"📰","aliases":["newspaper"],"tags":["press"],"category":"Objects","description":"newspaper","unicode_version":"6.0"},{"emoji":"🗞️","aliases":["newspaper_roll"],"tags":["press"],"category":"Objects","description":"rolled-up newspaper","unicode_version":"7.0"},{"emoji":"📑","aliases":["bookmark_tabs"],"tags":[],"category":"Objects","description":"bookmark tabs","unicode_version":"6.0"},{"emoji":"🔖","aliases":["bookmark"],"tags":[],"category":"Objects","description":"bookmark","unicode_version":"6.0"},{"emoji":"🏷️","aliases":["label"],"tags":["tag"],"category":"Objects","description":"label","unicode_version":"7.0"},{"emoji":"💰","aliases":["moneybag"],"tags":["dollar","cream"],"category":"Objects","description":"money bag","unicode_version":"6.0"},{"emoji":"🪙","aliases":["coin"],"tags":[],"category":"Objects","description":"coin","unicode_version":"13.0"},{"emoji":"💴","aliases":["yen"],"tags":[],"category":"Objects","description":"yen banknote","unicode_version":"6.0"},{"emoji":"💵","aliases":["dollar"],"tags":["money"],"category":"Objects","description":"dollar banknote","unicode_version":"6.0"},{"emoji":"💶","aliases":["euro"],"tags":[],"category":"Objects","description":"euro banknote","unicode_version":"6.0"},{"emoji":"💷","aliases":["pound"],"tags":[],"category":"Objects","description":"pound banknote","unicode_version":"6.0"},{"emoji":"💸","aliases":["money_with_wings"],"tags":["dollar"],"category":"Objects","description":"money with wings","unicode_version":"6.0"},{"emoji":"💳","aliases":["credit_card"],"tags":["subscription"],"category":"Objects","description":"credit card","unicode_version":"6.0"},{"emoji":"🧾","aliases":["receipt"],"tags":[],"category":"Objects","description":"receipt","unicode_version":"11.0"},{"emoji":"💹","aliases":["chart"],"tags":[],"category":"Objects","description":"chart increasing with yen","unicode_version":"6.0"},{"emoji":"✉️","aliases":["envelope"],"tags":["letter","email"],"category":"Objects","description":"envelope","unicode_version":""},{"emoji":"📧","aliases":["email","e-mail"],"tags":[],"category":"Objects","description":"e-mail","unicode_version":"6.0"},{"emoji":"📨","aliases":["incoming_envelope"],"tags":[],"category":"Objects","description":"incoming envelope","unicode_version":"6.0"},{"emoji":"📩","aliases":["envelope_with_arrow"],"tags":[],"category":"Objects","description":"envelope with arrow","unicode_version":"6.0"},{"emoji":"📤","aliases":["outbox_tray"],"tags":[],"category":"Objects","description":"outbox tray","unicode_version":"6.0"},{"emoji":"📥","aliases":["inbox_tray"],"tags":[],"category":"Objects","description":"inbox tray","unicode_version":"6.0"},{"emoji":"📦","aliases":["package"],"tags":["shipping"],"category":"Objects","description":"package","unicode_version":"6.0"},{"emoji":"📫","aliases":["mailbox"],"tags":[],"category":"Objects","description":"closed mailbox with raised flag","unicode_version":"6.0"},{"emoji":"📪","aliases":["mailbox_closed"],"tags":[],"category":"Objects","description":"closed mailbox with lowered flag","unicode_version":"6.0"},{"emoji":"📬","aliases":["mailbox_with_mail"],"tags":[],"category":"Objects","description":"open mailbox with raised flag","unicode_version":"6.0"},{"emoji":"📭","aliases":["mailbox_with_no_mail"],"tags":[],"category":"Objects","description":"open mailbox with lowered flag","unicode_version":"6.0"},{"emoji":"📮","aliases":["postbox"],"tags":[],"category":"Objects","description":"postbox","unicode_version":"6.0"},{"emoji":"🗳️","aliases":["ballot_box"],"tags":[],"category":"Objects","description":"ballot box with ballot","unicode_version":"7.0"},{"emoji":"✏️","aliases":["pencil2"],"tags":[],"category":"Objects","description":"pencil","unicode_version":""},{"emoji":"✒️","aliases":["black_nib"],"tags":[],"category":"Objects","description":"black nib","unicode_version":""},{"emoji":"🖋️","aliases":["fountain_pen"],"tags":[],"category":"Objects","description":"fountain pen","unicode_version":"7.0"},{"emoji":"🖊️","aliases":["pen"],"tags":[],"category":"Objects","description":"pen","unicode_version":"7.0"},{"emoji":"🖌️","aliases":["paintbrush"],"tags":[],"category":"Objects","description":"paintbrush","unicode_version":"7.0"},{"emoji":"🖍️","aliases":["crayon"],"tags":[],"category":"Objects","description":"crayon","unicode_version":"7.0"},{"emoji":"📝","aliases":["memo","pencil"],"tags":["document","note"],"category":"Objects","description":"memo","unicode_version":"6.0"},{"emoji":"💼","aliases":["briefcase"],"tags":["business"],"category":"Objects","description":"briefcase","unicode_version":"6.0"},{"emoji":"📁","aliases":["file_folder"],"tags":["directory"],"category":"Objects","description":"file folder","unicode_version":"6.0"},{"emoji":"📂","aliases":["open_file_folder"],"tags":[],"category":"Objects","description":"open file folder","unicode_version":"6.0"},{"emoji":"🗂️","aliases":["card_index_dividers"],"tags":[],"category":"Objects","description":"card index dividers","unicode_version":"7.0"},{"emoji":"📅","aliases":["date"],"tags":["calendar","schedule"],"category":"Objects","description":"calendar","unicode_version":"6.0"},{"emoji":"📆","aliases":["calendar"],"tags":["schedule"],"category":"Objects","description":"tear-off calendar","unicode_version":"6.0"},{"emoji":"🗒️","aliases":["spiral_notepad"],"tags":[],"category":"Objects","description":"spiral notepad","unicode_version":"7.0"},{"emoji":"🗓️","aliases":["spiral_calendar"],"tags":[],"category":"Objects","description":"spiral calendar","unicode_version":"7.0"},{"emoji":"📇","aliases":["card_index"],"tags":[],"category":"Objects","description":"card index","unicode_version":"6.0"},{"emoji":"📈","aliases":["chart_with_upwards_trend"],"tags":["graph","metrics"],"category":"Objects","description":"chart increasing","unicode_version":"6.0"},{"emoji":"📉","aliases":["chart_with_downwards_trend"],"tags":["graph","metrics"],"category":"Objects","description":"chart decreasing","unicode_version":"6.0"},{"emoji":"📊","aliases":["bar_chart"],"tags":["stats","metrics"],"category":"Objects","description":"bar chart","unicode_version":"6.0"},{"emoji":"📋","aliases":["clipboard"],"tags":[],"category":"Objects","description":"clipboard","unicode_version":"6.0"},{"emoji":"📌","aliases":["pushpin"],"tags":["location"],"category":"Objects","description":"pushpin","unicode_version":"6.0"},{"emoji":"📍","aliases":["round_pushpin"],"tags":["location"],"category":"Objects","description":"round pushpin","unicode_version":"6.0"},{"emoji":"📎","aliases":["paperclip"],"tags":[],"category":"Objects","description":"paperclip","unicode_version":"6.0"},{"emoji":"🖇️","aliases":["paperclips"],"tags":[],"category":"Objects","description":"linked paperclips","unicode_version":"7.0"},{"emoji":"📏","aliases":["straight_ruler"],"tags":[],"category":"Objects","description":"straight ruler","unicode_version":"6.0"},{"emoji":"📐","aliases":["triangular_ruler"],"tags":[],"category":"Objects","description":"triangular ruler","unicode_version":"6.0"},{"emoji":"✂️","aliases":["scissors"],"tags":["cut"],"category":"Objects","description":"scissors","unicode_version":""},{"emoji":"🗃️","aliases":["card_file_box"],"tags":[],"category":"Objects","description":"card file box","unicode_version":"7.0"},{"emoji":"🗄️","aliases":["file_cabinet"],"tags":[],"category":"Objects","description":"file cabinet","unicode_version":"7.0"},{"emoji":"🗑️","aliases":["wastebasket"],"tags":["trash"],"category":"Objects","description":"wastebasket","unicode_version":"7.0"},{"emoji":"🔒","aliases":["lock"],"tags":["security","private"],"category":"Objects","description":"locked","unicode_version":"6.0"},{"emoji":"🔓","aliases":["unlock"],"tags":["security"],"category":"Objects","description":"unlocked","unicode_version":"6.0"},{"emoji":"🔏","aliases":["lock_with_ink_pen"],"tags":[],"category":"Objects","description":"locked with pen","unicode_version":"6.0"},{"emoji":"🔐","aliases":["closed_lock_with_key"],"tags":["security"],"category":"Objects","description":"locked with key","unicode_version":"6.0"},{"emoji":"🔑","aliases":["key"],"tags":["lock","password"],"category":"Objects","description":"key","unicode_version":"6.0"},{"emoji":"🗝️","aliases":["old_key"],"tags":[],"category":"Objects","description":"old key","unicode_version":"7.0"},{"emoji":"🔨","aliases":["hammer"],"tags":["tool"],"category":"Objects","description":"hammer","unicode_version":"6.0"},{"emoji":"🪓","aliases":["axe"],"tags":[],"category":"Objects","description":"axe","unicode_version":"12.0"},{"emoji":"⛏️","aliases":["pick"],"tags":[],"category":"Objects","description":"pick","unicode_version":"5.2"},{"emoji":"⚒️","aliases":["hammer_and_pick"],"tags":[],"category":"Objects","description":"hammer and pick","unicode_version":"4.1"},{"emoji":"🛠️","aliases":["hammer_and_wrench"],"tags":[],"category":"Objects","description":"hammer and wrench","unicode_version":"7.0"},{"emoji":"🗡️","aliases":["dagger"],"tags":[],"category":"Objects","description":"dagger","unicode_version":"7.0"},{"emoji":"⚔️","aliases":["crossed_swords"],"tags":[],"category":"Objects","description":"crossed swords","unicode_version":"4.1"},{"emoji":"🔫","aliases":["gun"],"tags":["shoot","weapon"],"category":"Objects","description":"water pistol","unicode_version":"6.0"},{"emoji":"🪃","aliases":["boomerang"],"tags":[],"category":"Objects","description":"boomerang","unicode_version":"13.0"},{"emoji":"🏹","aliases":["bow_and_arrow"],"tags":["archery"],"category":"Objects","description":"bow and arrow","unicode_version":"8.0"},{"emoji":"🛡️","aliases":["shield"],"tags":[],"category":"Objects","description":"shield","unicode_version":"7.0"},{"emoji":"🪚","aliases":["carpentry_saw"],"tags":[],"category":"Objects","description":"carpentry saw","unicode_version":"13.0"},{"emoji":"🔧","aliases":["wrench"],"tags":["tool"],"category":"Objects","description":"wrench","unicode_version":"6.0"},{"emoji":"🪛","aliases":["screwdriver"],"tags":[],"category":"Objects","description":"screwdriver","unicode_version":"13.0"},{"emoji":"🔩","aliases":["nut_and_bolt"],"tags":[],"category":"Objects","description":"nut and bolt","unicode_version":"6.0"},{"emoji":"⚙️","aliases":["gear"],"tags":[],"category":"Objects","description":"gear","unicode_version":"4.1"},{"emoji":"🗜️","aliases":["clamp"],"tags":[],"category":"Objects","description":"clamp","unicode_version":"7.0"},{"emoji":"⚖️","aliases":["balance_scale"],"tags":[],"category":"Objects","description":"balance scale","unicode_version":"4.1"},{"emoji":"🦯","aliases":["probing_cane"],"tags":[],"category":"Objects","description":"white cane","unicode_version":"12.0"},{"emoji":"🔗","aliases":["link"],"tags":[],"category":"Objects","description":"link","unicode_version":"6.0"},{"emoji":"⛓️","aliases":["chains"],"tags":[],"category":"Objects","description":"chains","unicode_version":"5.2"},{"emoji":"🪝","aliases":["hook"],"tags":[],"category":"Objects","description":"hook","unicode_version":"13.0"},{"emoji":"🧰","aliases":["toolbox"],"tags":[],"category":"Objects","description":"toolbox","unicode_version":"11.0"},{"emoji":"🧲","aliases":["magnet"],"tags":[],"category":"Objects","description":"magnet","unicode_version":"11.0"},{"emoji":"🪜","aliases":["ladder"],"tags":[],"category":"Objects","description":"ladder","unicode_version":"13.0"},{"emoji":"⚗️","aliases":["alembic"],"tags":[],"category":"Objects","description":"alembic","unicode_version":"4.1"},{"emoji":"🧪","aliases":["test_tube"],"tags":[],"category":"Objects","description":"test tube","unicode_version":"11.0"},{"emoji":"🧫","aliases":["petri_dish"],"tags":[],"category":"Objects","description":"petri dish","unicode_version":"11.0"},{"emoji":"🧬","aliases":["dna"],"tags":[],"category":"Objects","description":"dna","unicode_version":"11.0"},{"emoji":"🔬","aliases":["microscope"],"tags":["science","laboratory","investigate"],"category":"Objects","description":"microscope","unicode_version":"6.0"},{"emoji":"🔭","aliases":["telescope"],"tags":[],"category":"Objects","description":"telescope","unicode_version":"6.0"},{"emoji":"📡","aliases":["satellite"],"tags":["signal"],"category":"Objects","description":"satellite antenna","unicode_version":"6.0"},{"emoji":"💉","aliases":["syringe"],"tags":["health","hospital","needle"],"category":"Objects","description":"syringe","unicode_version":"6.0"},{"emoji":"🩸","aliases":["drop_of_blood"],"tags":[],"category":"Objects","description":"drop of blood","unicode_version":"12.0"},{"emoji":"💊","aliases":["pill"],"tags":["health","medicine"],"category":"Objects","description":"pill","unicode_version":"6.0"},{"emoji":"🩹","aliases":["adhesive_bandage"],"tags":[],"category":"Objects","description":"adhesive bandage","unicode_version":"12.0"},{"emoji":"🩺","aliases":["stethoscope"],"tags":[],"category":"Objects","description":"stethoscope","unicode_version":"12.0"},{"emoji":"🚪","aliases":["door"],"tags":[],"category":"Objects","description":"door","unicode_version":"6.0"},{"emoji":"🛗","aliases":["elevator"],"tags":[],"category":"Objects","description":"elevator","unicode_version":"13.0"},{"emoji":"🪞","aliases":["mirror"],"tags":[],"category":"Objects","description":"mirror","unicode_version":"13.0"},{"emoji":"🪟","aliases":["window"],"tags":[],"category":"Objects","description":"window","unicode_version":"13.0"},{"emoji":"🛏️","aliases":["bed"],"tags":[],"category":"Objects","description":"bed","unicode_version":"7.0"},{"emoji":"🛋️","aliases":["couch_and_lamp"],"tags":[],"category":"Objects","description":"couch and lamp","unicode_version":"7.0"},{"emoji":"🪑","aliases":["chair"],"tags":[],"category":"Objects","description":"chair","unicode_version":"12.0"},{"emoji":"🚽","aliases":["toilet"],"tags":["wc"],"category":"Objects","description":"toilet","unicode_version":"6.0"},{"emoji":"🪠","aliases":["plunger"],"tags":[],"category":"Objects","description":"plunger","unicode_version":"13.0"},{"emoji":"🚿","aliases":["shower"],"tags":["bath"],"category":"Objects","description":"shower","unicode_version":"6.0"},{"emoji":"🛁","aliases":["bathtub"],"tags":[],"category":"Objects","description":"bathtub","unicode_version":"6.0"},{"emoji":"🪤","aliases":["mouse_trap"],"tags":[],"category":"Objects","description":"mouse trap","unicode_version":"13.0"},{"emoji":"🪒","aliases":["razor"],"tags":[],"category":"Objects","description":"razor","unicode_version":"12.0"},{"emoji":"🧴","aliases":["lotion_bottle"],"tags":[],"category":"Objects","description":"lotion bottle","unicode_version":"11.0"},{"emoji":"🧷","aliases":["safety_pin"],"tags":[],"category":"Objects","description":"safety pin","unicode_version":"11.0"},{"emoji":"🧹","aliases":["broom"],"tags":[],"category":"Objects","description":"broom","unicode_version":"11.0"},{"emoji":"🧺","aliases":["basket"],"tags":[],"category":"Objects","description":"basket","unicode_version":"11.0"},{"emoji":"🧻","aliases":["roll_of_paper"],"tags":["toilet"],"category":"Objects","description":"roll of paper","unicode_version":"11.0"},{"emoji":"🪣","aliases":["bucket"],"tags":[],"category":"Objects","description":"bucket","unicode_version":"13.0"},{"emoji":"🧼","aliases":["soap"],"tags":[],"category":"Objects","description":"soap","unicode_version":"11.0"},{"emoji":"🪥","aliases":["toothbrush"],"tags":[],"category":"Objects","description":"toothbrush","unicode_version":"13.0"},{"emoji":"🧽","aliases":["sponge"],"tags":[],"category":"Objects","description":"sponge","unicode_version":"11.0"},{"emoji":"🧯","aliases":["fire_extinguisher"],"tags":[],"category":"Objects","description":"fire extinguisher","unicode_version":"11.0"},{"emoji":"🛒","aliases":["shopping_cart"],"tags":[],"category":"Objects","description":"shopping cart","unicode_version":"9.0"},{"emoji":"🚬","aliases":["smoking"],"tags":["cigarette"],"category":"Objects","description":"cigarette","unicode_version":"6.0"},{"emoji":"⚰️","aliases":["coffin"],"tags":["funeral"],"category":"Objects","description":"coffin","unicode_version":"4.1"},{"emoji":"🪦","aliases":["headstone"],"tags":[],"category":"Objects","description":"headstone","unicode_version":"13.0"},{"emoji":"⚱️","aliases":["funeral_urn"],"tags":[],"category":"Objects","description":"funeral urn","unicode_version":"4.1"},{"emoji":"🗿","aliases":["moyai"],"tags":["stone"],"category":"Objects","description":"moai","unicode_version":"6.0"},{"emoji":"🪧","aliases":["placard"],"tags":[],"category":"Objects","description":"placard","unicode_version":"13.0"},{"emoji":"🏧","aliases":["atm"],"tags":[],"category":"Symbols","description":"ATM sign","unicode_version":"6.0"},{"emoji":"🚮","aliases":["put_litter_in_its_place"],"tags":[],"category":"Symbols","description":"litter in bin sign","unicode_version":"6.0"},{"emoji":"🚰","aliases":["potable_water"],"tags":[],"category":"Symbols","description":"potable water","unicode_version":"6.0"},{"emoji":"♿","aliases":["wheelchair"],"tags":["accessibility"],"category":"Symbols","description":"wheelchair symbol","unicode_version":"4.1"},{"emoji":"🚹","aliases":["mens"],"tags":[],"category":"Symbols","description":"men’s room","unicode_version":"6.0"},{"emoji":"🚺","aliases":["womens"],"tags":[],"category":"Symbols","description":"women’s room","unicode_version":"6.0"},{"emoji":"🚻","aliases":["restroom"],"tags":["toilet"],"category":"Symbols","description":"restroom","unicode_version":"6.0"},{"emoji":"🚼","aliases":["baby_symbol"],"tags":[],"category":"Symbols","description":"baby symbol","unicode_version":"6.0"},{"emoji":"🚾","aliases":["wc"],"tags":["toilet","restroom"],"category":"Symbols","description":"water closet","unicode_version":"6.0"},{"emoji":"🛂","aliases":["passport_control"],"tags":[],"category":"Symbols","description":"passport control","unicode_version":"6.0"},{"emoji":"🛃","aliases":["customs"],"tags":[],"category":"Symbols","description":"customs","unicode_version":"6.0"},{"emoji":"🛄","aliases":["baggage_claim"],"tags":["airport"],"category":"Symbols","description":"baggage claim","unicode_version":"6.0"},{"emoji":"🛅","aliases":["left_luggage"],"tags":[],"category":"Symbols","description":"left luggage","unicode_version":"6.0"},{"emoji":"⚠️","aliases":["warning"],"tags":["wip"],"category":"Symbols","description":"warning","unicode_version":"4.0"},{"emoji":"🚸","aliases":["children_crossing"],"tags":[],"category":"Symbols","description":"children crossing","unicode_version":"6.0"},{"emoji":"⛔","aliases":["no_entry"],"tags":["limit"],"category":"Symbols","description":"no entry","unicode_version":"5.2"},{"emoji":"🚫","aliases":["no_entry_sign"],"tags":["block","forbidden"],"category":"Symbols","description":"prohibited","unicode_version":"6.0"},{"emoji":"🚳","aliases":["no_bicycles"],"tags":[],"category":"Symbols","description":"no bicycles","unicode_version":"6.0"},{"emoji":"🚭","aliases":["no_smoking"],"tags":[],"category":"Symbols","description":"no smoking","unicode_version":"6.0"},{"emoji":"🚯","aliases":["do_not_litter"],"tags":[],"category":"Symbols","description":"no littering","unicode_version":"6.0"},{"emoji":"🚱","aliases":["non-potable_water"],"tags":[],"category":"Symbols","description":"non-potable water","unicode_version":"6.0"},{"emoji":"🚷","aliases":["no_pedestrians"],"tags":[],"category":"Symbols","description":"no pedestrians","unicode_version":"6.0"},{"emoji":"📵","aliases":["no_mobile_phones"],"tags":[],"category":"Symbols","description":"no mobile phones","unicode_version":"6.0"},{"emoji":"🔞","aliases":["underage"],"tags":[],"category":"Symbols","description":"no one under eighteen","unicode_version":"6.0"},{"emoji":"☢️","aliases":["radioactive"],"tags":[],"category":"Symbols","description":"radioactive","unicode_version":""},{"emoji":"☣️","aliases":["biohazard"],"tags":[],"category":"Symbols","description":"biohazard","unicode_version":""},{"emoji":"⬆️","aliases":["arrow_up"],"tags":[],"category":"Symbols","description":"up arrow","unicode_version":"4.0"},{"emoji":"↗️","aliases":["arrow_upper_right"],"tags":[],"category":"Symbols","description":"up-right arrow","unicode_version":""},{"emoji":"➡️","aliases":["arrow_right"],"tags":[],"category":"Symbols","description":"right arrow","unicode_version":""},{"emoji":"↘️","aliases":["arrow_lower_right"],"tags":[],"category":"Symbols","description":"down-right arrow","unicode_version":""},{"emoji":"⬇️","aliases":["arrow_down"],"tags":[],"category":"Symbols","description":"down arrow","unicode_version":"4.0"},{"emoji":"↙️","aliases":["arrow_lower_left"],"tags":[],"category":"Symbols","description":"down-left arrow","unicode_version":""},{"emoji":"⬅️","aliases":["arrow_left"],"tags":[],"category":"Symbols","description":"left arrow","unicode_version":"4.0"},{"emoji":"↖️","aliases":["arrow_upper_left"],"tags":[],"category":"Symbols","description":"up-left arrow","unicode_version":""},{"emoji":"↕️","aliases":["arrow_up_down"],"tags":[],"category":"Symbols","description":"up-down arrow","unicode_version":""},{"emoji":"↔️","aliases":["left_right_arrow"],"tags":[],"category":"Symbols","description":"left-right arrow","unicode_version":""},{"emoji":"↩️","aliases":["leftwards_arrow_with_hook"],"tags":["return"],"category":"Symbols","description":"right arrow curving left","unicode_version":""},{"emoji":"↪️","aliases":["arrow_right_hook"],"tags":[],"category":"Symbols","description":"left arrow curving right","unicode_version":""},{"emoji":"⤴️","aliases":["arrow_heading_up"],"tags":[],"category":"Symbols","description":"right arrow curving up","unicode_version":""},{"emoji":"⤵️","aliases":["arrow_heading_down"],"tags":[],"category":"Symbols","description":"right arrow curving down","unicode_version":""},{"emoji":"🔃","aliases":["arrows_clockwise"],"tags":[],"category":"Symbols","description":"clockwise vertical arrows","unicode_version":"6.0"},{"emoji":"🔄","aliases":["arrows_counterclockwise"],"tags":["sync"],"category":"Symbols","description":"counterclockwise arrows button","unicode_version":"6.0"},{"emoji":"🔙","aliases":["back"],"tags":[],"category":"Symbols","description":"BACK arrow","unicode_version":"6.0"},{"emoji":"🔚","aliases":["end"],"tags":[],"category":"Symbols","description":"END arrow","unicode_version":"6.0"},{"emoji":"🔛","aliases":["on"],"tags":[],"category":"Symbols","description":"ON! arrow","unicode_version":"6.0"},{"emoji":"🔜","aliases":["soon"],"tags":[],"category":"Symbols","description":"SOON arrow","unicode_version":"6.0"},{"emoji":"🔝","aliases":["top"],"tags":[],"category":"Symbols","description":"TOP arrow","unicode_version":"6.0"},{"emoji":"🛐","aliases":["place_of_worship"],"tags":[],"category":"Symbols","description":"place of worship","unicode_version":"8.0"},{"emoji":"⚛️","aliases":["atom_symbol"],"tags":[],"category":"Symbols","description":"atom symbol","unicode_version":"4.1"},{"emoji":"🕉️","aliases":["om"],"tags":[],"category":"Symbols","description":"om","unicode_version":"7.0"},{"emoji":"✡️","aliases":["star_of_david"],"tags":[],"category":"Symbols","description":"star of David","unicode_version":""},{"emoji":"☸️","aliases":["wheel_of_dharma"],"tags":[],"category":"Symbols","description":"wheel of dharma","unicode_version":""},{"emoji":"☯️","aliases":["yin_yang"],"tags":[],"category":"Symbols","description":"yin yang","unicode_version":""},{"emoji":"✝️","aliases":["latin_cross"],"tags":[],"category":"Symbols","description":"latin cross","unicode_version":""},{"emoji":"☦️","aliases":["orthodox_cross"],"tags":[],"category":"Symbols","description":"orthodox cross","unicode_version":""},{"emoji":"☪️","aliases":["star_and_crescent"],"tags":[],"category":"Symbols","description":"star and crescent","unicode_version":""},{"emoji":"☮️","aliases":["peace_symbol"],"tags":[],"category":"Symbols","description":"peace symbol","unicode_version":""},{"emoji":"🕎","aliases":["menorah"],"tags":[],"category":"Symbols","description":"menorah","unicode_version":"8.0"},{"emoji":"🔯","aliases":["six_pointed_star"],"tags":[],"category":"Symbols","description":"dotted six-pointed star","unicode_version":"6.0"},{"emoji":"♈","aliases":["aries"],"tags":[],"category":"Symbols","description":"Aries","unicode_version":""},{"emoji":"♉","aliases":["taurus"],"tags":[],"category":"Symbols","description":"Taurus","unicode_version":""},{"emoji":"♊","aliases":["gemini"],"tags":[],"category":"Symbols","description":"Gemini","unicode_version":""},{"emoji":"♋","aliases":["cancer"],"tags":[],"category":"Symbols","description":"Cancer","unicode_version":""},{"emoji":"♌","aliases":["leo"],"tags":[],"category":"Symbols","description":"Leo","unicode_version":""},{"emoji":"♍","aliases":["virgo"],"tags":[],"category":"Symbols","description":"Virgo","unicode_version":""},{"emoji":"♎","aliases":["libra"],"tags":[],"category":"Symbols","description":"Libra","unicode_version":""},{"emoji":"♏","aliases":["scorpius"],"tags":[],"category":"Symbols","description":"Scorpio","unicode_version":""},{"emoji":"♐","aliases":["sagittarius"],"tags":[],"category":"Symbols","description":"Sagittarius","unicode_version":""},{"emoji":"♑","aliases":["capricorn"],"tags":[],"category":"Symbols","description":"Capricorn","unicode_version":""},{"emoji":"♒","aliases":["aquarius"],"tags":[],"category":"Symbols","description":"Aquarius","unicode_version":""},{"emoji":"♓","aliases":["pisces"],"tags":[],"category":"Symbols","description":"Pisces","unicode_version":""},{"emoji":"⛎","aliases":["ophiuchus"],"tags":[],"category":"Symbols","description":"Ophiuchus","unicode_version":"6.0"},{"emoji":"🔀","aliases":["twisted_rightwards_arrows"],"tags":["shuffle"],"category":"Symbols","description":"shuffle tracks button","unicode_version":"6.0"},{"emoji":"🔁","aliases":["repeat"],"tags":["loop"],"category":"Symbols","description":"repeat button","unicode_version":"6.0"},{"emoji":"🔂","aliases":["repeat_one"],"tags":[],"category":"Symbols","description":"repeat single button","unicode_version":"6.0"},{"emoji":"▶️","aliases":["arrow_forward"],"tags":[],"category":"Symbols","description":"play button","unicode_version":""},{"emoji":"⏩","aliases":["fast_forward"],"tags":[],"category":"Symbols","description":"fast-forward button","unicode_version":"6.0"},{"emoji":"⏭️","aliases":["next_track_button"],"tags":[],"category":"Symbols","description":"next track button","unicode_version":"6.0"},{"emoji":"⏯️","aliases":["play_or_pause_button"],"tags":[],"category":"Symbols","description":"play or pause button","unicode_version":"6.0"},{"emoji":"◀️","aliases":["arrow_backward"],"tags":[],"category":"Symbols","description":"reverse button","unicode_version":""},{"emoji":"⏪","aliases":["rewind"],"tags":[],"category":"Symbols","description":"fast reverse button","unicode_version":"6.0"},{"emoji":"⏮️","aliases":["previous_track_button"],"tags":[],"category":"Symbols","description":"last track button","unicode_version":"6.0"},{"emoji":"🔼","aliases":["arrow_up_small"],"tags":[],"category":"Symbols","description":"upwards button","unicode_version":"6.0"},{"emoji":"⏫","aliases":["arrow_double_up"],"tags":[],"category":"Symbols","description":"fast up button","unicode_version":"6.0"},{"emoji":"🔽","aliases":["arrow_down_small"],"tags":[],"category":"Symbols","description":"downwards button","unicode_version":"6.0"},{"emoji":"⏬","aliases":["arrow_double_down"],"tags":[],"category":"Symbols","description":"fast down button","unicode_version":"6.0"},{"emoji":"⏸️","aliases":["pause_button"],"tags":[],"category":"Symbols","description":"pause button","unicode_version":"7.0"},{"emoji":"⏹️","aliases":["stop_button"],"tags":[],"category":"Symbols","description":"stop button","unicode_version":"7.0"},{"emoji":"⏺️","aliases":["record_button"],"tags":[],"category":"Symbols","description":"record button","unicode_version":"7.0"},{"emoji":"⏏️","aliases":["eject_button"],"tags":[],"category":"Symbols","description":"eject button","unicode_version":"11.0"},{"emoji":"🎦","aliases":["cinema"],"tags":["film","movie"],"category":"Symbols","description":"cinema","unicode_version":"6.0"},{"emoji":"🔅","aliases":["low_brightness"],"tags":[],"category":"Symbols","description":"dim button","unicode_version":"6.0"},{"emoji":"🔆","aliases":["high_brightness"],"tags":[],"category":"Symbols","description":"bright button","unicode_version":"6.0"},{"emoji":"📶","aliases":["signal_strength"],"tags":["wifi"],"category":"Symbols","description":"antenna bars","unicode_version":"6.0"},{"emoji":"📳","aliases":["vibration_mode"],"tags":[],"category":"Symbols","description":"vibration mode","unicode_version":"6.0"},{"emoji":"📴","aliases":["mobile_phone_off"],"tags":["mute","off"],"category":"Symbols","description":"mobile phone off","unicode_version":"6.0"},{"emoji":"♀️","aliases":["female_sign"],"tags":[],"category":"Symbols","description":"female sign","unicode_version":"11.0"},{"emoji":"♂️","aliases":["male_sign"],"tags":[],"category":"Symbols","description":"male sign","unicode_version":"11.0"},{"emoji":"⚧️","aliases":["transgender_symbol"],"tags":[],"category":"Symbols","description":"transgender symbol","unicode_version":"13.0"},{"emoji":"✖️","aliases":["heavy_multiplication_x"],"tags":[],"category":"Symbols","description":"multiply","unicode_version":""},{"emoji":"➕","aliases":["heavy_plus_sign"],"tags":[],"category":"Symbols","description":"plus","unicode_version":"6.0"},{"emoji":"➖","aliases":["heavy_minus_sign"],"tags":[],"category":"Symbols","description":"minus","unicode_version":"6.0"},{"emoji":"➗","aliases":["heavy_division_sign"],"tags":[],"category":"Symbols","description":"divide","unicode_version":"6.0"},{"emoji":"♾️","aliases":["infinity"],"tags":[],"category":"Symbols","description":"infinity","unicode_version":"11.0"},{"emoji":"‼️","aliases":["bangbang"],"tags":[],"category":"Symbols","description":"double exclamation mark","unicode_version":""},{"emoji":"⁉️","aliases":["interrobang"],"tags":[],"category":"Symbols","description":"exclamation question mark","unicode_version":"3.0"},{"emoji":"❓","aliases":["question"],"tags":["confused"],"category":"Symbols","description":"red question mark","unicode_version":"6.0"},{"emoji":"❔","aliases":["grey_question"],"tags":[],"category":"Symbols","description":"white question mark","unicode_version":"6.0"},{"emoji":"❕","aliases":["grey_exclamation"],"tags":[],"category":"Symbols","description":"white exclamation mark","unicode_version":"6.0"},{"emoji":"❗","aliases":["exclamation","heavy_exclamation_mark"],"tags":["bang"],"category":"Symbols","description":"red exclamation mark","unicode_version":"5.2"},{"emoji":"〰️","aliases":["wavy_dash"],"tags":[],"category":"Symbols","description":"wavy dash","unicode_version":""},{"emoji":"💱","aliases":["currency_exchange"],"tags":[],"category":"Symbols","description":"currency exchange","unicode_version":"6.0"},{"emoji":"💲","aliases":["heavy_dollar_sign"],"tags":[],"category":"Symbols","description":"heavy dollar sign","unicode_version":"6.0"},{"emoji":"⚕️","aliases":["medical_symbol"],"tags":[],"category":"Symbols","description":"medical symbol","unicode_version":"11.0"},{"emoji":"♻️","aliases":["recycle"],"tags":["environment","green"],"category":"Symbols","description":"recycling symbol","unicode_version":"3.2"},{"emoji":"⚜️","aliases":["fleur_de_lis"],"tags":[],"category":"Symbols","description":"fleur-de-lis","unicode_version":"4.1"},{"emoji":"🔱","aliases":["trident"],"tags":[],"category":"Symbols","description":"trident emblem","unicode_version":"6.0"},{"emoji":"📛","aliases":["name_badge"],"tags":[],"category":"Symbols","description":"name badge","unicode_version":"6.0"},{"emoji":"🔰","aliases":["beginner"],"tags":[],"category":"Symbols","description":"Japanese symbol for beginner","unicode_version":"6.0"},{"emoji":"⭕","aliases":["o"],"tags":[],"category":"Symbols","description":"hollow red circle","unicode_version":"5.2"},{"emoji":"✅","aliases":["white_check_mark"],"tags":[],"category":"Symbols","description":"check mark button","unicode_version":"6.0"},{"emoji":"☑️","aliases":["ballot_box_with_check"],"tags":[],"category":"Symbols","description":"check box with check","unicode_version":""},{"emoji":"✔️","aliases":["heavy_check_mark"],"tags":[],"category":"Symbols","description":"check mark","unicode_version":""},{"emoji":"❌","aliases":["x"],"tags":[],"category":"Symbols","description":"cross mark","unicode_version":"6.0"},{"emoji":"❎","aliases":["negative_squared_cross_mark"],"tags":[],"category":"Symbols","description":"cross mark button","unicode_version":"6.0"},{"emoji":"➰","aliases":["curly_loop"],"tags":[],"category":"Symbols","description":"curly loop","unicode_version":"6.0"},{"emoji":"➿","aliases":["loop"],"tags":[],"category":"Symbols","description":"double curly loop","unicode_version":"6.0"},{"emoji":"〽️","aliases":["part_alternation_mark"],"tags":[],"category":"Symbols","description":"part alternation mark","unicode_version":"3.2"},{"emoji":"✳️","aliases":["eight_spoked_asterisk"],"tags":[],"category":"Symbols","description":"eight-spoked asterisk","unicode_version":""},{"emoji":"✴️","aliases":["eight_pointed_black_star"],"tags":[],"category":"Symbols","description":"eight-pointed star","unicode_version":""},{"emoji":"❇️","aliases":["sparkle"],"tags":[],"category":"Symbols","description":"sparkle","unicode_version":""},{"emoji":"©️","aliases":["copyright"],"tags":[],"category":"Symbols","description":"copyright","unicode_version":""},{"emoji":"®️","aliases":["registered"],"tags":[],"category":"Symbols","description":"registered","unicode_version":""},{"emoji":"™️","aliases":["tm"],"tags":["trademark"],"category":"Symbols","description":"trade mark","unicode_version":""},{"emoji":"#️⃣","aliases":["hash"],"tags":["number"],"category":"Symbols","description":"keycap: #","unicode_version":""},{"emoji":"*️⃣","aliases":["asterisk"],"tags":[],"category":"Symbols","description":"keycap: *","unicode_version":""},{"emoji":"0️⃣","aliases":["zero"],"tags":[],"category":"Symbols","description":"keycap: 0","unicode_version":""},{"emoji":"1️⃣","aliases":["one"],"tags":[],"category":"Symbols","description":"keycap: 1","unicode_version":""},{"emoji":"2️⃣","aliases":["two"],"tags":[],"category":"Symbols","description":"keycap: 2","unicode_version":""},{"emoji":"3️⃣","aliases":["three"],"tags":[],"category":"Symbols","description":"keycap: 3","unicode_version":""},{"emoji":"4️⃣","aliases":["four"],"tags":[],"category":"Symbols","description":"keycap: 4","unicode_version":""},{"emoji":"5️⃣","aliases":["five"],"tags":[],"category":"Symbols","description":"keycap: 5","unicode_version":""},{"emoji":"6️⃣","aliases":["six"],"tags":[],"category":"Symbols","description":"keycap: 6","unicode_version":""},{"emoji":"7️⃣","aliases":["seven"],"tags":[],"category":"Symbols","description":"keycap: 7","unicode_version":""},{"emoji":"8️⃣","aliases":["eight"],"tags":[],"category":"Symbols","description":"keycap: 8","unicode_version":""},{"emoji":"9️⃣","aliases":["nine"],"tags":[],"category":"Symbols","description":"keycap: 9","unicode_version":""},{"emoji":"🔟","aliases":["keycap_ten"],"tags":[],"category":"Symbols","description":"keycap: 10","unicode_version":"6.0"},{"emoji":"🔠","aliases":["capital_abcd"],"tags":["letters"],"category":"Symbols","description":"input latin uppercase","unicode_version":"6.0"},{"emoji":"🔡","aliases":["abcd"],"tags":[],"category":"Symbols","description":"input latin lowercase","unicode_version":"6.0"},{"emoji":"🔢","aliases":["1234"],"tags":["numbers"],"category":"Symbols","description":"input numbers","unicode_version":"6.0"},{"emoji":"🔣","aliases":["symbols"],"tags":[],"category":"Symbols","description":"input symbols","unicode_version":"6.0"},{"emoji":"🔤","aliases":["abc"],"tags":["alphabet"],"category":"Symbols","description":"input latin letters","unicode_version":"6.0"},{"emoji":"🅰️","aliases":["a"],"tags":[],"category":"Symbols","description":"A button (blood type)","unicode_version":"6.0"},{"emoji":"🆎","aliases":["ab"],"tags":[],"category":"Symbols","description":"AB button (blood type)","unicode_version":"6.0"},{"emoji":"🅱️","aliases":["b"],"tags":[],"category":"Symbols","description":"B button (blood type)","unicode_version":"6.0"},{"emoji":"🆑","aliases":["cl"],"tags":[],"category":"Symbols","description":"CL button","unicode_version":"6.0"},{"emoji":"🆒","aliases":["cool"],"tags":[],"category":"Symbols","description":"COOL button","unicode_version":"6.0"},{"emoji":"🆓","aliases":["free"],"tags":[],"category":"Symbols","description":"FREE button","unicode_version":"6.0"},{"emoji":"ℹ️","aliases":["information_source"],"tags":[],"category":"Symbols","description":"information","unicode_version":"3.0"},{"emoji":"🆔","aliases":["id"],"tags":[],"category":"Symbols","description":"ID button","unicode_version":"6.0"},{"emoji":"Ⓜ️","aliases":["m"],"tags":[],"category":"Symbols","description":"circled M","unicode_version":""},{"emoji":"🆕","aliases":["new"],"tags":["fresh"],"category":"Symbols","description":"NEW button","unicode_version":"6.0"},{"emoji":"🆖","aliases":["ng"],"tags":[],"category":"Symbols","description":"NG button","unicode_version":"6.0"},{"emoji":"🅾️","aliases":["o2"],"tags":[],"category":"Symbols","description":"O button (blood type)","unicode_version":"6.0"},{"emoji":"🆗","aliases":["ok"],"tags":["yes"],"category":"Symbols","description":"OK button","unicode_version":"6.0"},{"emoji":"🅿️","aliases":["parking"],"tags":[],"category":"Symbols","description":"P button","unicode_version":"5.2"},{"emoji":"🆘","aliases":["sos"],"tags":["help","emergency"],"category":"Symbols","description":"SOS button","unicode_version":"6.0"},{"emoji":"🆙","aliases":["up"],"tags":[],"category":"Symbols","description":"UP! button","unicode_version":"6.0"},{"emoji":"🆚","aliases":["vs"],"tags":[],"category":"Symbols","description":"VS button","unicode_version":"6.0"},{"emoji":"🈁","aliases":["koko"],"tags":[],"category":"Symbols","description":"Japanese “here” button","unicode_version":"6.0"},{"emoji":"🈂️","aliases":["sa"],"tags":[],"category":"Symbols","description":"Japanese “service charge” button","unicode_version":"6.0"},{"emoji":"🈷️","aliases":["u6708"],"tags":[],"category":"Symbols","description":"Japanese “monthly amount” button","unicode_version":"6.0"},{"emoji":"🈶","aliases":["u6709"],"tags":[],"category":"Symbols","description":"Japanese “not free of charge” button","unicode_version":"6.0"},{"emoji":"🈯","aliases":["u6307"],"tags":[],"category":"Symbols","description":"Japanese “reserved” button","unicode_version":""},{"emoji":"🉐","aliases":["ideograph_advantage"],"tags":[],"category":"Symbols","description":"Japanese “bargain” button","unicode_version":"6.0"},{"emoji":"🈹","aliases":["u5272"],"tags":[],"category":"Symbols","description":"Japanese “discount” button","unicode_version":"6.0"},{"emoji":"🈚","aliases":["u7121"],"tags":[],"category":"Symbols","description":"Japanese “free of charge” button","unicode_version":""},{"emoji":"🈲","aliases":["u7981"],"tags":[],"category":"Symbols","description":"Japanese “prohibited” button","unicode_version":"6.0"},{"emoji":"🉑","aliases":["accept"],"tags":[],"category":"Symbols","description":"Japanese “acceptable” button","unicode_version":"6.0"},{"emoji":"🈸","aliases":["u7533"],"tags":[],"category":"Symbols","description":"Japanese “application” button","unicode_version":"6.0"},{"emoji":"🈴","aliases":["u5408"],"tags":[],"category":"Symbols","description":"Japanese “passing grade” button","unicode_version":"6.0"},{"emoji":"🈳","aliases":["u7a7a"],"tags":[],"category":"Symbols","description":"Japanese “vacancy” button","unicode_version":"6.0"},{"emoji":"㊗️","aliases":["congratulations"],"tags":[],"category":"Symbols","description":"Japanese “congratulations” button","unicode_version":""},{"emoji":"㊙️","aliases":["secret"],"tags":[],"category":"Symbols","description":"Japanese “secret” button","unicode_version":""},{"emoji":"🈺","aliases":["u55b6"],"tags":[],"category":"Symbols","description":"Japanese “open for business” button","unicode_version":"6.0"},{"emoji":"🈵","aliases":["u6e80"],"tags":[],"category":"Symbols","description":"Japanese “no vacancy” button","unicode_version":"6.0"},{"emoji":"🔴","aliases":["red_circle"],"tags":[],"category":"Symbols","description":"red circle","unicode_version":"6.0"},{"emoji":"🟠","aliases":["orange_circle"],"tags":[],"category":"Symbols","description":"orange circle","unicode_version":"12.0"},{"emoji":"🟡","aliases":["yellow_circle"],"tags":[],"category":"Symbols","description":"yellow circle","unicode_version":"12.0"},{"emoji":"🟢","aliases":["green_circle"],"tags":[],"category":"Symbols","description":"green circle","unicode_version":"12.0"},{"emoji":"🔵","aliases":["large_blue_circle"],"tags":[],"category":"Symbols","description":"blue circle","unicode_version":"6.0"},{"emoji":"🟣","aliases":["purple_circle"],"tags":[],"category":"Symbols","description":"purple circle","unicode_version":"12.0"},{"emoji":"🟤","aliases":["brown_circle"],"tags":[],"category":"Symbols","description":"brown circle","unicode_version":"12.0"},{"emoji":"⚫","aliases":["black_circle"],"tags":[],"category":"Symbols","description":"black circle","unicode_version":"4.1"},{"emoji":"⚪","aliases":["white_circle"],"tags":[],"category":"Symbols","description":"white circle","unicode_version":"4.1"},{"emoji":"🟥","aliases":["red_square"],"tags":[],"category":"Symbols","description":"red square","unicode_version":"12.0"},{"emoji":"🟧","aliases":["orange_square"],"tags":[],"category":"Symbols","description":"orange square","unicode_version":"12.0"},{"emoji":"🟨","aliases":["yellow_square"],"tags":[],"category":"Symbols","description":"yellow square","unicode_version":"12.0"},{"emoji":"🟩","aliases":["green_square"],"tags":[],"category":"Symbols","description":"green square","unicode_version":"12.0"},{"emoji":"🟦","aliases":["blue_square"],"tags":[],"category":"Symbols","description":"blue square","unicode_version":"12.0"},{"emoji":"🟪","aliases":["purple_square"],"tags":[],"category":"Symbols","description":"purple square","unicode_version":"12.0"},{"emoji":"🟫","aliases":["brown_square"],"tags":[],"category":"Symbols","description":"brown square","unicode_version":"12.0"},{"emoji":"⬛","aliases":["black_large_square"],"tags":[],"category":"Symbols","description":"black large square","unicode_version":"5.1"},{"emoji":"⬜","aliases":["white_large_square"],"tags":[],"category":"Symbols","description":"white large square","unicode_version":"5.1"},{"emoji":"◼️","aliases":["black_medium_square"],"tags":[],"category":"Symbols","description":"black medium square","unicode_version":"3.2"},{"emoji":"◻️","aliases":["white_medium_square"],"tags":[],"category":"Symbols","description":"white medium square","unicode_version":"3.2"},{"emoji":"◾","aliases":["black_medium_small_square"],"tags":[],"category":"Symbols","description":"black medium-small square","unicode_version":"3.2"},{"emoji":"◽","aliases":["white_medium_small_square"],"tags":[],"category":"Symbols","description":"white medium-small square","unicode_version":"3.2"},{"emoji":"▪️","aliases":["black_small_square"],"tags":[],"category":"Symbols","description":"black small square","unicode_version":""},{"emoji":"▫️","aliases":["white_small_square"],"tags":[],"category":"Symbols","description":"white small square","unicode_version":""},{"emoji":"🔶","aliases":["large_orange_diamond"],"tags":[],"category":"Symbols","description":"large orange diamond","unicode_version":"6.0"},{"emoji":"🔷","aliases":["large_blue_diamond"],"tags":[],"category":"Symbols","description":"large blue diamond","unicode_version":"6.0"},{"emoji":"🔸","aliases":["small_orange_diamond"],"tags":[],"category":"Symbols","description":"small orange diamond","unicode_version":"6.0"},{"emoji":"🔹","aliases":["small_blue_diamond"],"tags":[],"category":"Symbols","description":"small blue diamond","unicode_version":"6.0"},{"emoji":"🔺","aliases":["small_red_triangle"],"tags":[],"category":"Symbols","description":"red triangle pointed up","unicode_version":"6.0"},{"emoji":"🔻","aliases":["small_red_triangle_down"],"tags":[],"category":"Symbols","description":"red triangle pointed down","unicode_version":"6.0"},{"emoji":"💠","aliases":["diamond_shape_with_a_dot_inside"],"tags":[],"category":"Symbols","description":"diamond with a dot","unicode_version":"6.0"},{"emoji":"🔘","aliases":["radio_button"],"tags":[],"category":"Symbols","description":"radio button","unicode_version":"6.0"},{"emoji":"🔳","aliases":["white_square_button"],"tags":[],"category":"Symbols","description":"white square button","unicode_version":"6.0"},{"emoji":"🔲","aliases":["black_square_button"],"tags":[],"category":"Symbols","description":"black square button","unicode_version":"6.0"},{"emoji":"🏁","aliases":["checkered_flag"],"tags":["milestone","finish"],"category":"Flags","description":"chequered flag","unicode_version":"6.0"},{"emoji":"🚩","aliases":["triangular_flag_on_post"],"tags":[],"category":"Flags","description":"triangular flag","unicode_version":"6.0"},{"emoji":"🎌","aliases":["crossed_flags"],"tags":[],"category":"Flags","description":"crossed flags","unicode_version":"6.0"},{"emoji":"🏴","aliases":["black_flag"],"tags":[],"category":"Flags","description":"black flag","unicode_version":"7.0"},{"emoji":"🏳️","aliases":["white_flag"],"tags":[],"category":"Flags","description":"white flag","unicode_version":"7.0"},{"emoji":"🏳️‍🌈","aliases":["rainbow_flag"],"tags":["pride"],"category":"Flags","description":"rainbow flag","unicode_version":"6.0"},{"emoji":"🏳️‍⚧️","aliases":["transgender_flag"],"tags":[],"category":"Flags","description":"transgender flag","unicode_version":"13.0"},{"emoji":"🏴‍☠️","aliases":["pirate_flag"],"tags":[],"category":"Flags","description":"pirate flag","unicode_version":"11.0"},{"emoji":"🇦🇨","aliases":["ascension_island"],"tags":[],"category":"Flags","description":"flag: Ascension Island","unicode_version":"11.0"},{"emoji":"🇦🇩","aliases":["andorra"],"tags":[],"category":"Flags","description":"flag: Andorra","unicode_version":"6.0"},{"emoji":"🇦🇪","aliases":["united_arab_emirates"],"tags":[],"category":"Flags","description":"flag: United Arab Emirates","unicode_version":"6.0"},{"emoji":"🇦🇫","aliases":["afghanistan"],"tags":[],"category":"Flags","description":"flag: Afghanistan","unicode_version":"6.0"},{"emoji":"🇦🇬","aliases":["antigua_barbuda"],"tags":[],"category":"Flags","description":"flag: Antigua & Barbuda","unicode_version":"6.0"},{"emoji":"🇦🇮","aliases":["anguilla"],"tags":[],"category":"Flags","description":"flag: Anguilla","unicode_version":"6.0"},{"emoji":"🇦🇱","aliases":["albania"],"tags":[],"category":"Flags","description":"flag: Albania","unicode_version":"6.0"},{"emoji":"🇦🇲","aliases":["armenia"],"tags":[],"category":"Flags","description":"flag: Armenia","unicode_version":"6.0"},{"emoji":"🇦🇴","aliases":["angola"],"tags":[],"category":"Flags","description":"flag: Angola","unicode_version":"6.0"},{"emoji":"🇦🇶","aliases":["antarctica"],"tags":[],"category":"Flags","description":"flag: Antarctica","unicode_version":"6.0"},{"emoji":"🇦🇷","aliases":["argentina"],"tags":[],"category":"Flags","description":"flag: Argentina","unicode_version":"6.0"},{"emoji":"🇦🇸","aliases":["american_samoa"],"tags":[],"category":"Flags","description":"flag: American Samoa","unicode_version":"6.0"},{"emoji":"🇦🇹","aliases":["austria"],"tags":[],"category":"Flags","description":"flag: Austria","unicode_version":"6.0"},{"emoji":"🇦🇺","aliases":["australia"],"tags":[],"category":"Flags","description":"flag: Australia","unicode_version":"6.0"},{"emoji":"🇦🇼","aliases":["aruba"],"tags":[],"category":"Flags","description":"flag: Aruba","unicode_version":"6.0"},{"emoji":"🇦🇽","aliases":["aland_islands"],"tags":[],"category":"Flags","description":"flag: Åland Islands","unicode_version":"6.0"},{"emoji":"🇦🇿","aliases":["azerbaijan"],"tags":[],"category":"Flags","description":"flag: Azerbaijan","unicode_version":"6.0"},{"emoji":"🇧🇦","aliases":["bosnia_herzegovina"],"tags":[],"category":"Flags","description":"flag: Bosnia & Herzegovina","unicode_version":"6.0"},{"emoji":"🇧🇧","aliases":["barbados"],"tags":[],"category":"Flags","description":"flag: Barbados","unicode_version":"6.0"},{"emoji":"🇧🇩","aliases":["bangladesh"],"tags":[],"category":"Flags","description":"flag: Bangladesh","unicode_version":"6.0"},{"emoji":"🇧🇪","aliases":["belgium"],"tags":[],"category":"Flags","description":"flag: Belgium","unicode_version":"6.0"},{"emoji":"🇧🇫","aliases":["burkina_faso"],"tags":[],"category":"Flags","description":"flag: Burkina Faso","unicode_version":"6.0"},{"emoji":"🇧🇬","aliases":["bulgaria"],"tags":[],"category":"Flags","description":"flag: Bulgaria","unicode_version":"6.0"},{"emoji":"🇧🇭","aliases":["bahrain"],"tags":[],"category":"Flags","description":"flag: Bahrain","unicode_version":"6.0"},{"emoji":"🇧🇮","aliases":["burundi"],"tags":[],"category":"Flags","description":"flag: Burundi","unicode_version":"6.0"},{"emoji":"🇧🇯","aliases":["benin"],"tags":[],"category":"Flags","description":"flag: Benin","unicode_version":"6.0"},{"emoji":"🇧🇱","aliases":["st_barthelemy"],"tags":[],"category":"Flags","description":"flag: St. Barthélemy","unicode_version":"6.0"},{"emoji":"🇧🇲","aliases":["bermuda"],"tags":[],"category":"Flags","description":"flag: Bermuda","unicode_version":"6.0"},{"emoji":"🇧🇳","aliases":["brunei"],"tags":[],"category":"Flags","description":"flag: Brunei","unicode_version":"6.0"},{"emoji":"🇧🇴","aliases":["bolivia"],"tags":[],"category":"Flags","description":"flag: Bolivia","unicode_version":"6.0"},{"emoji":"🇧🇶","aliases":["caribbean_netherlands"],"tags":[],"category":"Flags","description":"flag: Caribbean Netherlands","unicode_version":"6.0"},{"emoji":"🇧🇷","aliases":["brazil"],"tags":[],"category":"Flags","description":"flag: Brazil","unicode_version":"6.0"},{"emoji":"🇧🇸","aliases":["bahamas"],"tags":[],"category":"Flags","description":"flag: Bahamas","unicode_version":"6.0"},{"emoji":"🇧🇹","aliases":["bhutan"],"tags":[],"category":"Flags","description":"flag: Bhutan","unicode_version":"6.0"},{"emoji":"🇧🇻","aliases":["bouvet_island"],"tags":[],"category":"Flags","description":"flag: Bouvet Island","unicode_version":"11.0"},{"emoji":"🇧🇼","aliases":["botswana"],"tags":[],"category":"Flags","description":"flag: Botswana","unicode_version":"6.0"},{"emoji":"🇧🇾","aliases":["belarus"],"tags":[],"category":"Flags","description":"flag: Belarus","unicode_version":"6.0"},{"emoji":"🇧🇿","aliases":["belize"],"tags":[],"category":"Flags","description":"flag: Belize","unicode_version":"6.0"},{"emoji":"🇨🇦","aliases":["canada"],"tags":[],"category":"Flags","description":"flag: Canada","unicode_version":"6.0"},{"emoji":"🇨🇨","aliases":["cocos_islands"],"tags":["keeling"],"category":"Flags","description":"flag: Cocos (Keeling) Islands","unicode_version":"6.0"},{"emoji":"🇨🇩","aliases":["congo_kinshasa"],"tags":[],"category":"Flags","description":"flag: Congo - Kinshasa","unicode_version":"6.0"},{"emoji":"🇨🇫","aliases":["central_african_republic"],"tags":[],"category":"Flags","description":"flag: Central African Republic","unicode_version":"6.0"},{"emoji":"🇨🇬","aliases":["congo_brazzaville"],"tags":[],"category":"Flags","description":"flag: Congo - Brazzaville","unicode_version":"6.0"},{"emoji":"🇨🇭","aliases":["switzerland"],"tags":[],"category":"Flags","description":"flag: Switzerland","unicode_version":"6.0"},{"emoji":"🇨🇮","aliases":["cote_divoire"],"tags":["ivory"],"category":"Flags","description":"flag: Côte d’Ivoire","unicode_version":"6.0"},{"emoji":"🇨🇰","aliases":["cook_islands"],"tags":[],"category":"Flags","description":"flag: Cook Islands","unicode_version":"6.0"},{"emoji":"🇨🇱","aliases":["chile"],"tags":[],"category":"Flags","description":"flag: Chile","unicode_version":"6.0"},{"emoji":"🇨🇲","aliases":["cameroon"],"tags":[],"category":"Flags","description":"flag: Cameroon","unicode_version":"6.0"},{"emoji":"🇨🇳","aliases":["cn"],"tags":["china"],"category":"Flags","description":"flag: China","unicode_version":"6.0"},{"emoji":"🇨🇴","aliases":["colombia"],"tags":[],"category":"Flags","description":"flag: Colombia","unicode_version":"6.0"},{"emoji":"🇨🇵","aliases":["clipperton_island"],"tags":[],"category":"Flags","description":"flag: Clipperton Island","unicode_version":"11.0"},{"emoji":"🇨🇷","aliases":["costa_rica"],"tags":[],"category":"Flags","description":"flag: Costa Rica","unicode_version":"6.0"},{"emoji":"🇨🇺","aliases":["cuba"],"tags":[],"category":"Flags","description":"flag: Cuba","unicode_version":"6.0"},{"emoji":"🇨🇻","aliases":["cape_verde"],"tags":[],"category":"Flags","description":"flag: Cape Verde","unicode_version":"6.0"},{"emoji":"🇨🇼","aliases":["curacao"],"tags":[],"category":"Flags","description":"flag: Curaçao","unicode_version":"6.0"},{"emoji":"🇨🇽","aliases":["christmas_island"],"tags":[],"category":"Flags","description":"flag: Christmas Island","unicode_version":"6.0"},{"emoji":"🇨🇾","aliases":["cyprus"],"tags":[],"category":"Flags","description":"flag: Cyprus","unicode_version":"6.0"},{"emoji":"🇨🇿","aliases":["czech_republic"],"tags":[],"category":"Flags","description":"flag: Czechia","unicode_version":"6.0"},{"emoji":"🇩🇪","aliases":["de"],"tags":["flag","germany"],"category":"Flags","description":"flag: Germany","unicode_version":"6.0"},{"emoji":"🇩🇬","aliases":["diego_garcia"],"tags":[],"category":"Flags","description":"flag: Diego Garcia","unicode_version":"11.0"},{"emoji":"🇩🇯","aliases":["djibouti"],"tags":[],"category":"Flags","description":"flag: Djibouti","unicode_version":"6.0"},{"emoji":"🇩🇰","aliases":["denmark"],"tags":[],"category":"Flags","description":"flag: Denmark","unicode_version":"6.0"},{"emoji":"🇩🇲","aliases":["dominica"],"tags":[],"category":"Flags","description":"flag: Dominica","unicode_version":"6.0"},{"emoji":"🇩🇴","aliases":["dominican_republic"],"tags":[],"category":"Flags","description":"flag: Dominican Republic","unicode_version":"6.0"},{"emoji":"🇩🇿","aliases":["algeria"],"tags":[],"category":"Flags","description":"flag: Algeria","unicode_version":"6.0"},{"emoji":"🇪🇦","aliases":["ceuta_melilla"],"tags":[],"category":"Flags","description":"flag: Ceuta & Melilla","unicode_version":"11.0"},{"emoji":"🇪🇨","aliases":["ecuador"],"tags":[],"category":"Flags","description":"flag: Ecuador","unicode_version":"6.0"},{"emoji":"🇪🇪","aliases":["estonia"],"tags":[],"category":"Flags","description":"flag: Estonia","unicode_version":"6.0"},{"emoji":"🇪🇬","aliases":["egypt"],"tags":[],"category":"Flags","description":"flag: Egypt","unicode_version":"6.0"},{"emoji":"🇪🇭","aliases":["western_sahara"],"tags":[],"category":"Flags","description":"flag: Western Sahara","unicode_version":"6.0"},{"emoji":"🇪🇷","aliases":["eritrea"],"tags":[],"category":"Flags","description":"flag: Eritrea","unicode_version":"6.0"},{"emoji":"🇪🇸","aliases":["es"],"tags":["spain"],"category":"Flags","description":"flag: Spain","unicode_version":"6.0"},{"emoji":"🇪🇹","aliases":["ethiopia"],"tags":[],"category":"Flags","description":"flag: Ethiopia","unicode_version":"6.0"},{"emoji":"🇪🇺","aliases":["eu","european_union"],"tags":[],"category":"Flags","description":"flag: European Union","unicode_version":"6.0"},{"emoji":"🇫🇮","aliases":["finland"],"tags":[],"category":"Flags","description":"flag: Finland","unicode_version":"6.0"},{"emoji":"🇫🇯","aliases":["fiji"],"tags":[],"category":"Flags","description":"flag: Fiji","unicode_version":"6.0"},{"emoji":"🇫🇰","aliases":["falkland_islands"],"tags":[],"category":"Flags","description":"flag: Falkland Islands","unicode_version":"6.0"},{"emoji":"🇫🇲","aliases":["micronesia"],"tags":[],"category":"Flags","description":"flag: Micronesia","unicode_version":"6.0"},{"emoji":"🇫🇴","aliases":["faroe_islands"],"tags":[],"category":"Flags","description":"flag: Faroe Islands","unicode_version":"6.0"},{"emoji":"🇫🇷","aliases":["fr"],"tags":["france","french"],"category":"Flags","description":"flag: France","unicode_version":"6.0"},{"emoji":"🇬🇦","aliases":["gabon"],"tags":[],"category":"Flags","description":"flag: Gabon","unicode_version":"6.0"},{"emoji":"🇬🇧","aliases":["gb","uk"],"tags":["flag","british"],"category":"Flags","description":"flag: United Kingdom","unicode_version":"6.0"},{"emoji":"🇬🇩","aliases":["grenada"],"tags":[],"category":"Flags","description":"flag: Grenada","unicode_version":"6.0"},{"emoji":"🇬🇪","aliases":["georgia"],"tags":[],"category":"Flags","description":"flag: Georgia","unicode_version":"6.0"},{"emoji":"🇬🇫","aliases":["french_guiana"],"tags":[],"category":"Flags","description":"flag: French Guiana","unicode_version":"6.0"},{"emoji":"🇬🇬","aliases":["guernsey"],"tags":[],"category":"Flags","description":"flag: Guernsey","unicode_version":"6.0"},{"emoji":"🇬🇭","aliases":["ghana"],"tags":[],"category":"Flags","description":"flag: Ghana","unicode_version":"6.0"},{"emoji":"🇬🇮","aliases":["gibraltar"],"tags":[],"category":"Flags","description":"flag: Gibraltar","unicode_version":"6.0"},{"emoji":"🇬🇱","aliases":["greenland"],"tags":[],"category":"Flags","description":"flag: Greenland","unicode_version":"6.0"},{"emoji":"🇬🇲","aliases":["gambia"],"tags":[],"category":"Flags","description":"flag: Gambia","unicode_version":"6.0"},{"emoji":"🇬🇳","aliases":["guinea"],"tags":[],"category":"Flags","description":"flag: Guinea","unicode_version":"6.0"},{"emoji":"🇬🇵","aliases":["guadeloupe"],"tags":[],"category":"Flags","description":"flag: Guadeloupe","unicode_version":"6.0"},{"emoji":"🇬🇶","aliases":["equatorial_guinea"],"tags":[],"category":"Flags","description":"flag: Equatorial Guinea","unicode_version":"6.0"},{"emoji":"🇬🇷","aliases":["greece"],"tags":[],"category":"Flags","description":"flag: Greece","unicode_version":"6.0"},{"emoji":"🇬🇸","aliases":["south_georgia_south_sandwich_islands"],"tags":[],"category":"Flags","description":"flag: South Georgia & South Sandwich Islands","unicode_version":"6.0"},{"emoji":"🇬🇹","aliases":["guatemala"],"tags":[],"category":"Flags","description":"flag: Guatemala","unicode_version":"6.0"},{"emoji":"🇬🇺","aliases":["guam"],"tags":[],"category":"Flags","description":"flag: Guam","unicode_version":"6.0"},{"emoji":"🇬🇼","aliases":["guinea_bissau"],"tags":[],"category":"Flags","description":"flag: Guinea-Bissau","unicode_version":"6.0"},{"emoji":"🇬🇾","aliases":["guyana"],"tags":[],"category":"Flags","description":"flag: Guyana","unicode_version":"6.0"},{"emoji":"🇭🇰","aliases":["hong_kong"],"tags":[],"category":"Flags","description":"flag: Hong Kong SAR China","unicode_version":"6.0"},{"emoji":"🇭🇲","aliases":["heard_mcdonald_islands"],"tags":[],"category":"Flags","description":"flag: Heard & McDonald Islands","unicode_version":"11.0"},{"emoji":"🇭🇳","aliases":["honduras"],"tags":[],"category":"Flags","description":"flag: Honduras","unicode_version":"6.0"},{"emoji":"🇭🇷","aliases":["croatia"],"tags":[],"category":"Flags","description":"flag: Croatia","unicode_version":"6.0"},{"emoji":"🇭🇹","aliases":["haiti"],"tags":[],"category":"Flags","description":"flag: Haiti","unicode_version":"6.0"},{"emoji":"🇭🇺","aliases":["hungary"],"tags":[],"category":"Flags","description":"flag: Hungary","unicode_version":"6.0"},{"emoji":"🇮🇨","aliases":["canary_islands"],"tags":[],"category":"Flags","description":"flag: Canary Islands","unicode_version":"6.0"},{"emoji":"🇮🇩","aliases":["indonesia"],"tags":[],"category":"Flags","description":"flag: Indonesia","unicode_version":"6.0"},{"emoji":"🇮🇪","aliases":["ireland"],"tags":[],"category":"Flags","description":"flag: Ireland","unicode_version":"6.0"},{"emoji":"🇮🇱","aliases":["israel"],"tags":[],"category":"Flags","description":"flag: Israel","unicode_version":"6.0"},{"emoji":"🇮🇲","aliases":["isle_of_man"],"tags":[],"category":"Flags","description":"flag: Isle of Man","unicode_version":"6.0"},{"emoji":"🇮🇳","aliases":["india"],"tags":[],"category":"Flags","description":"flag: India","unicode_version":"6.0"},{"emoji":"🇮🇴","aliases":["british_indian_ocean_territory"],"tags":[],"category":"Flags","description":"flag: British Indian Ocean Territory","unicode_version":"6.0"},{"emoji":"🇮🇶","aliases":["iraq"],"tags":[],"category":"Flags","description":"flag: Iraq","unicode_version":"6.0"},{"emoji":"🇮🇷","aliases":["iran"],"tags":[],"category":"Flags","description":"flag: Iran","unicode_version":"6.0"},{"emoji":"🇮🇸","aliases":["iceland"],"tags":[],"category":"Flags","description":"flag: Iceland","unicode_version":"6.0"},{"emoji":"🇮🇹","aliases":["it"],"tags":["italy"],"category":"Flags","description":"flag: Italy","unicode_version":"6.0"},{"emoji":"🇯🇪","aliases":["jersey"],"tags":[],"category":"Flags","description":"flag: Jersey","unicode_version":"6.0"},{"emoji":"🇯🇲","aliases":["jamaica"],"tags":[],"category":"Flags","description":"flag: Jamaica","unicode_version":"6.0"},{"emoji":"🇯🇴","aliases":["jordan"],"tags":[],"category":"Flags","description":"flag: Jordan","unicode_version":"6.0"},{"emoji":"🇯🇵","aliases":["jp"],"tags":["japan"],"category":"Flags","description":"flag: Japan","unicode_version":"6.0"},{"emoji":"🇰🇪","aliases":["kenya"],"tags":[],"category":"Flags","description":"flag: Kenya","unicode_version":"6.0"},{"emoji":"🇰🇬","aliases":["kyrgyzstan"],"tags":[],"category":"Flags","description":"flag: Kyrgyzstan","unicode_version":"6.0"},{"emoji":"🇰🇭","aliases":["cambodia"],"tags":[],"category":"Flags","description":"flag: Cambodia","unicode_version":"6.0"},{"emoji":"🇰🇮","aliases":["kiribati"],"tags":[],"category":"Flags","description":"flag: Kiribati","unicode_version":"6.0"},{"emoji":"🇰🇲","aliases":["comoros"],"tags":[],"category":"Flags","description":"flag: Comoros","unicode_version":"6.0"},{"emoji":"🇰🇳","aliases":["st_kitts_nevis"],"tags":[],"category":"Flags","description":"flag: St. Kitts & Nevis","unicode_version":"6.0"},{"emoji":"🇰🇵","aliases":["north_korea"],"tags":[],"category":"Flags","description":"flag: North Korea","unicode_version":"6.0"},{"emoji":"🇰🇷","aliases":["kr"],"tags":["korea"],"category":"Flags","description":"flag: South Korea","unicode_version":"6.0"},{"emoji":"🇰🇼","aliases":["kuwait"],"tags":[],"category":"Flags","description":"flag: Kuwait","unicode_version":"6.0"},{"emoji":"🇰🇾","aliases":["cayman_islands"],"tags":[],"category":"Flags","description":"flag: Cayman Islands","unicode_version":"6.0"},{"emoji":"🇰🇿","aliases":["kazakhstan"],"tags":[],"category":"Flags","description":"flag: Kazakhstan","unicode_version":"6.0"},{"emoji":"🇱🇦","aliases":["laos"],"tags":[],"category":"Flags","description":"flag: Laos","unicode_version":"6.0"},{"emoji":"🇱🇧","aliases":["lebanon"],"tags":[],"category":"Flags","description":"flag: Lebanon","unicode_version":"6.0"},{"emoji":"🇱🇨","aliases":["st_lucia"],"tags":[],"category":"Flags","description":"flag: St. Lucia","unicode_version":"6.0"},{"emoji":"🇱🇮","aliases":["liechtenstein"],"tags":[],"category":"Flags","description":"flag: Liechtenstein","unicode_version":"6.0"},{"emoji":"🇱🇰","aliases":["sri_lanka"],"tags":[],"category":"Flags","description":"flag: Sri Lanka","unicode_version":"6.0"},{"emoji":"🇱🇷","aliases":["liberia"],"tags":[],"category":"Flags","description":"flag: Liberia","unicode_version":"6.0"},{"emoji":"🇱🇸","aliases":["lesotho"],"tags":[],"category":"Flags","description":"flag: Lesotho","unicode_version":"6.0"},{"emoji":"🇱🇹","aliases":["lithuania"],"tags":[],"category":"Flags","description":"flag: Lithuania","unicode_version":"6.0"},{"emoji":"🇱🇺","aliases":["luxembourg"],"tags":[],"category":"Flags","description":"flag: Luxembourg","unicode_version":"6.0"},{"emoji":"🇱🇻","aliases":["latvia"],"tags":[],"category":"Flags","description":"flag: Latvia","unicode_version":"6.0"},{"emoji":"🇱🇾","aliases":["libya"],"tags":[],"category":"Flags","description":"flag: Libya","unicode_version":"6.0"},{"emoji":"🇲🇦","aliases":["morocco"],"tags":[],"category":"Flags","description":"flag: Morocco","unicode_version":"6.0"},{"emoji":"🇲🇨","aliases":["monaco"],"tags":[],"category":"Flags","description":"flag: Monaco","unicode_version":"6.0"},{"emoji":"🇲🇩","aliases":["moldova"],"tags":[],"category":"Flags","description":"flag: Moldova","unicode_version":"6.0"},{"emoji":"🇲🇪","aliases":["montenegro"],"tags":[],"category":"Flags","description":"flag: Montenegro","unicode_version":"6.0"},{"emoji":"🇲🇫","aliases":["st_martin"],"tags":[],"category":"Flags","description":"flag: St. Martin","unicode_version":"11.0"},{"emoji":"🇲🇬","aliases":["madagascar"],"tags":[],"category":"Flags","description":"flag: Madagascar","unicode_version":"6.0"},{"emoji":"🇲🇭","aliases":["marshall_islands"],"tags":[],"category":"Flags","description":"flag: Marshall Islands","unicode_version":"6.0"},{"emoji":"🇲🇰","aliases":["macedonia"],"tags":[],"category":"Flags","description":"flag: North Macedonia","unicode_version":"6.0"},{"emoji":"🇲🇱","aliases":["mali"],"tags":[],"category":"Flags","description":"flag: Mali","unicode_version":"6.0"},{"emoji":"🇲🇲","aliases":["myanmar"],"tags":["burma"],"category":"Flags","description":"flag: Myanmar (Burma)","unicode_version":"6.0"},{"emoji":"🇲🇳","aliases":["mongolia"],"tags":[],"category":"Flags","description":"flag: Mongolia","unicode_version":"6.0"},{"emoji":"🇲🇴","aliases":["macau"],"tags":[],"category":"Flags","description":"flag: Macao SAR China","unicode_version":"6.0"},{"emoji":"🇲🇵","aliases":["northern_mariana_islands"],"tags":[],"category":"Flags","description":"flag: Northern Mariana Islands","unicode_version":"6.0"},{"emoji":"🇲🇶","aliases":["martinique"],"tags":[],"category":"Flags","description":"flag: Martinique","unicode_version":"6.0"},{"emoji":"🇲🇷","aliases":["mauritania"],"tags":[],"category":"Flags","description":"flag: Mauritania","unicode_version":"6.0"},{"emoji":"🇲🇸","aliases":["montserrat"],"tags":[],"category":"Flags","description":"flag: Montserrat","unicode_version":"6.0"},{"emoji":"🇲🇹","aliases":["malta"],"tags":[],"category":"Flags","description":"flag: Malta","unicode_version":"6.0"},{"emoji":"🇲🇺","aliases":["mauritius"],"tags":[],"category":"Flags","description":"flag: Mauritius","unicode_version":"6.0"},{"emoji":"🇲🇻","aliases":["maldives"],"tags":[],"category":"Flags","description":"flag: Maldives","unicode_version":"6.0"},{"emoji":"🇲🇼","aliases":["malawi"],"tags":[],"category":"Flags","description":"flag: Malawi","unicode_version":"6.0"},{"emoji":"🇲🇽","aliases":["mexico"],"tags":[],"category":"Flags","description":"flag: Mexico","unicode_version":"6.0"},{"emoji":"🇲🇾","aliases":["malaysia"],"tags":[],"category":"Flags","description":"flag: Malaysia","unicode_version":"6.0"},{"emoji":"🇲🇿","aliases":["mozambique"],"tags":[],"category":"Flags","description":"flag: Mozambique","unicode_version":"6.0"},{"emoji":"🇳🇦","aliases":["namibia"],"tags":[],"category":"Flags","description":"flag: Namibia","unicode_version":"6.0"},{"emoji":"🇳🇨","aliases":["new_caledonia"],"tags":[],"category":"Flags","description":"flag: New Caledonia","unicode_version":"6.0"},{"emoji":"🇳🇪","aliases":["niger"],"tags":[],"category":"Flags","description":"flag: Niger","unicode_version":"6.0"},{"emoji":"🇳🇫","aliases":["norfolk_island"],"tags":[],"category":"Flags","description":"flag: Norfolk Island","unicode_version":"6.0"},{"emoji":"🇳🇬","aliases":["nigeria"],"tags":[],"category":"Flags","description":"flag: Nigeria","unicode_version":"6.0"},{"emoji":"🇳🇮","aliases":["nicaragua"],"tags":[],"category":"Flags","description":"flag: Nicaragua","unicode_version":"6.0"},{"emoji":"🇳🇱","aliases":["netherlands"],"tags":[],"category":"Flags","description":"flag: Netherlands","unicode_version":"6.0"},{"emoji":"🇳🇴","aliases":["norway"],"tags":[],"category":"Flags","description":"flag: Norway","unicode_version":"6.0"},{"emoji":"🇳🇵","aliases":["nepal"],"tags":[],"category":"Flags","description":"flag: Nepal","unicode_version":"6.0"},{"emoji":"🇳🇷","aliases":["nauru"],"tags":[],"category":"Flags","description":"flag: Nauru","unicode_version":"6.0"},{"emoji":"🇳🇺","aliases":["niue"],"tags":[],"category":"Flags","description":"flag: Niue","unicode_version":"6.0"},{"emoji":"🇳🇿","aliases":["new_zealand"],"tags":[],"category":"Flags","description":"flag: New Zealand","unicode_version":"6.0"},{"emoji":"🇴🇲","aliases":["oman"],"tags":[],"category":"Flags","description":"flag: Oman","unicode_version":"6.0"},{"emoji":"🇵🇦","aliases":["panama"],"tags":[],"category":"Flags","description":"flag: Panama","unicode_version":"6.0"},{"emoji":"🇵🇪","aliases":["peru"],"tags":[],"category":"Flags","description":"flag: Peru","unicode_version":"6.0"},{"emoji":"🇵🇫","aliases":["french_polynesia"],"tags":[],"category":"Flags","description":"flag: French Polynesia","unicode_version":"6.0"},{"emoji":"🇵🇬","aliases":["papua_new_guinea"],"tags":[],"category":"Flags","description":"flag: Papua New Guinea","unicode_version":"6.0"},{"emoji":"🇵🇭","aliases":["philippines"],"tags":[],"category":"Flags","description":"flag: Philippines","unicode_version":"6.0"},{"emoji":"🇵🇰","aliases":["pakistan"],"tags":[],"category":"Flags","description":"flag: Pakistan","unicode_version":"6.0"},{"emoji":"🇵🇱","aliases":["poland"],"tags":[],"category":"Flags","description":"flag: Poland","unicode_version":"6.0"},{"emoji":"🇵🇲","aliases":["st_pierre_miquelon"],"tags":[],"category":"Flags","description":"flag: St. Pierre & Miquelon","unicode_version":"6.0"},{"emoji":"🇵🇳","aliases":["pitcairn_islands"],"tags":[],"category":"Flags","description":"flag: Pitcairn Islands","unicode_version":"6.0"},{"emoji":"🇵🇷","aliases":["puerto_rico"],"tags":[],"category":"Flags","description":"flag: Puerto Rico","unicode_version":"6.0"},{"emoji":"🇵🇸","aliases":["palestinian_territories"],"tags":[],"category":"Flags","description":"flag: Palestinian Territories","unicode_version":"6.0"},{"emoji":"🇵🇹","aliases":["portugal"],"tags":[],"category":"Flags","description":"flag: Portugal","unicode_version":"6.0"},{"emoji":"🇵🇼","aliases":["palau"],"tags":[],"category":"Flags","description":"flag: Palau","unicode_version":"6.0"},{"emoji":"🇵🇾","aliases":["paraguay"],"tags":[],"category":"Flags","description":"flag: Paraguay","unicode_version":"6.0"},{"emoji":"🇶🇦","aliases":["qatar"],"tags":[],"category":"Flags","description":"flag: Qatar","unicode_version":"6.0"},{"emoji":"🇷🇪","aliases":["reunion"],"tags":[],"category":"Flags","description":"flag: Réunion","unicode_version":"6.0"},{"emoji":"🇷🇴","aliases":["romania"],"tags":[],"category":"Flags","description":"flag: Romania","unicode_version":"6.0"},{"emoji":"🇷🇸","aliases":["serbia"],"tags":[],"category":"Flags","description":"flag: Serbia","unicode_version":"6.0"},{"emoji":"🇷🇺","aliases":["ru"],"tags":["russia"],"category":"Flags","description":"flag: Russia","unicode_version":"6.0"},{"emoji":"🇷🇼","aliases":["rwanda"],"tags":[],"category":"Flags","description":"flag: Rwanda","unicode_version":"6.0"},{"emoji":"🇸🇦","aliases":["saudi_arabia"],"tags":[],"category":"Flags","description":"flag: Saudi Arabia","unicode_version":"6.0"},{"emoji":"🇸🇧","aliases":["solomon_islands"],"tags":[],"category":"Flags","description":"flag: Solomon Islands","unicode_version":"6.0"},{"emoji":"🇸🇨","aliases":["seychelles"],"tags":[],"category":"Flags","description":"flag: Seychelles","unicode_version":"6.0"},{"emoji":"🇸🇩","aliases":["sudan"],"tags":[],"category":"Flags","description":"flag: Sudan","unicode_version":"6.0"},{"emoji":"🇸🇪","aliases":["sweden"],"tags":[],"category":"Flags","description":"flag: Sweden","unicode_version":"6.0"},{"emoji":"🇸🇬","aliases":["singapore"],"tags":[],"category":"Flags","description":"flag: Singapore","unicode_version":"6.0"},{"emoji":"🇸🇭","aliases":["st_helena"],"tags":[],"category":"Flags","description":"flag: St. Helena","unicode_version":"6.0"},{"emoji":"🇸🇮","aliases":["slovenia"],"tags":[],"category":"Flags","description":"flag: Slovenia","unicode_version":"6.0"},{"emoji":"🇸🇯","aliases":["svalbard_jan_mayen"],"tags":[],"category":"Flags","description":"flag: Svalbard & Jan Mayen","unicode_version":"11.0"},{"emoji":"🇸🇰","aliases":["slovakia"],"tags":[],"category":"Flags","description":"flag: Slovakia","unicode_version":"6.0"},{"emoji":"🇸🇱","aliases":["sierra_leone"],"tags":[],"category":"Flags","description":"flag: Sierra Leone","unicode_version":"6.0"},{"emoji":"🇸🇲","aliases":["san_marino"],"tags":[],"category":"Flags","description":"flag: San Marino","unicode_version":"6.0"},{"emoji":"🇸🇳","aliases":["senegal"],"tags":[],"category":"Flags","description":"flag: Senegal","unicode_version":"6.0"},{"emoji":"🇸🇴","aliases":["somalia"],"tags":[],"category":"Flags","description":"flag: Somalia","unicode_version":"6.0"},{"emoji":"🇸🇷","aliases":["suriname"],"tags":[],"category":"Flags","description":"flag: Suriname","unicode_version":"6.0"},{"emoji":"🇸🇸","aliases":["south_sudan"],"tags":[],"category":"Flags","description":"flag: South Sudan","unicode_version":"6.0"},{"emoji":"🇸🇹","aliases":["sao_tome_principe"],"tags":[],"category":"Flags","description":"flag: São Tomé & Príncipe","unicode_version":"6.0"},{"emoji":"🇸🇻","aliases":["el_salvador"],"tags":[],"category":"Flags","description":"flag: El Salvador","unicode_version":"6.0"},{"emoji":"🇸🇽","aliases":["sint_maarten"],"tags":[],"category":"Flags","description":"flag: Sint Maarten","unicode_version":"6.0"},{"emoji":"🇸🇾","aliases":["syria"],"tags":[],"category":"Flags","description":"flag: Syria","unicode_version":"6.0"},{"emoji":"🇸🇿","aliases":["swaziland"],"tags":[],"category":"Flags","description":"flag: Eswatini","unicode_version":"6.0"},{"emoji":"🇹🇦","aliases":["tristan_da_cunha"],"tags":[],"category":"Flags","description":"flag: Tristan da Cunha","unicode_version":"11.0"},{"emoji":"🇹🇨","aliases":["turks_caicos_islands"],"tags":[],"category":"Flags","description":"flag: Turks & Caicos Islands","unicode_version":"6.0"},{"emoji":"🇹🇩","aliases":["chad"],"tags":[],"category":"Flags","description":"flag: Chad","unicode_version":"6.0"},{"emoji":"🇹🇫","aliases":["french_southern_territories"],"tags":[],"category":"Flags","description":"flag: French Southern Territories","unicode_version":"6.0"},{"emoji":"🇹🇬","aliases":["togo"],"tags":[],"category":"Flags","description":"flag: Togo","unicode_version":"6.0"},{"emoji":"🇹🇭","aliases":["thailand"],"tags":[],"category":"Flags","description":"flag: Thailand","unicode_version":"6.0"},{"emoji":"🇹🇯","aliases":["tajikistan"],"tags":[],"category":"Flags","description":"flag: Tajikistan","unicode_version":"6.0"},{"emoji":"🇹🇰","aliases":["tokelau"],"tags":[],"category":"Flags","description":"flag: Tokelau","unicode_version":"6.0"},{"emoji":"🇹🇱","aliases":["timor_leste"],"tags":[],"category":"Flags","description":"flag: Timor-Leste","unicode_version":"6.0"},{"emoji":"🇹🇲","aliases":["turkmenistan"],"tags":[],"category":"Flags","description":"flag: Turkmenistan","unicode_version":"6.0"},{"emoji":"🇹🇳","aliases":["tunisia"],"tags":[],"category":"Flags","description":"flag: Tunisia","unicode_version":"6.0"},{"emoji":"🇹🇴","aliases":["tonga"],"tags":[],"category":"Flags","description":"flag: Tonga","unicode_version":"6.0"},{"emoji":"🇹🇷","aliases":["tr"],"tags":["turkey"],"category":"Flags","description":"flag: Turkey","unicode_version":"8.0"},{"emoji":"🇹🇹","aliases":["trinidad_tobago"],"tags":[],"category":"Flags","description":"flag: Trinidad & Tobago","unicode_version":"6.0"},{"emoji":"🇹🇻","aliases":["tuvalu"],"tags":[],"category":"Flags","description":"flag: Tuvalu","unicode_version":"6.0"},{"emoji":"🇹🇼","aliases":["taiwan"],"tags":[],"category":"Flags","description":"flag: Taiwan","unicode_version":"6.0"},{"emoji":"🇹🇿","aliases":["tanzania"],"tags":[],"category":"Flags","description":"flag: Tanzania","unicode_version":"6.0"},{"emoji":"🇺🇦","aliases":["ukraine"],"tags":[],"category":"Flags","description":"flag: Ukraine","unicode_version":"6.0"},{"emoji":"🇺🇬","aliases":["uganda"],"tags":[],"category":"Flags","description":"flag: Uganda","unicode_version":"6.0"},{"emoji":"🇺🇲","aliases":["us_outlying_islands"],"tags":[],"category":"Flags","description":"flag: U.S. Outlying Islands","unicode_version":"11.0"},{"emoji":"🇺🇳","aliases":["united_nations"],"tags":[],"category":"Flags","description":"flag: United Nations","unicode_version":"11.0"},{"emoji":"🇺🇸","aliases":["us"],"tags":["flag","united","america"],"category":"Flags","description":"flag: United States","unicode_version":"6.0"},{"emoji":"🇺🇾","aliases":["uruguay"],"tags":[],"category":"Flags","description":"flag: Uruguay","unicode_version":"6.0"},{"emoji":"🇺🇿","aliases":["uzbekistan"],"tags":[],"category":"Flags","description":"flag: Uzbekistan","unicode_version":"6.0"},{"emoji":"🇻🇦","aliases":["vatican_city"],"tags":[],"category":"Flags","description":"flag: Vatican City","unicode_version":"6.0"},{"emoji":"🇻🇨","aliases":["st_vincent_grenadines"],"tags":[],"category":"Flags","description":"flag: St. Vincent & Grenadines","unicode_version":"6.0"},{"emoji":"🇻🇪","aliases":["venezuela"],"tags":[],"category":"Flags","description":"flag: Venezuela","unicode_version":"6.0"},{"emoji":"🇻🇬","aliases":["british_virgin_islands"],"tags":[],"category":"Flags","description":"flag: British Virgin Islands","unicode_version":"6.0"},{"emoji":"🇻🇮","aliases":["us_virgin_islands"],"tags":[],"category":"Flags","description":"flag: U.S. Virgin Islands","unicode_version":"6.0"},{"emoji":"🇻🇳","aliases":["vietnam"],"tags":[],"category":"Flags","description":"flag: Vietnam","unicode_version":"6.0"},{"emoji":"🇻🇺","aliases":["vanuatu"],"tags":[],"category":"Flags","description":"flag: Vanuatu","unicode_version":"6.0"},{"emoji":"🇼🇫","aliases":["wallis_futuna"],"tags":[],"category":"Flags","description":"flag: Wallis & Futuna","unicode_version":"6.0"},{"emoji":"🇼🇸","aliases":["samoa"],"tags":[],"category":"Flags","description":"flag: Samoa","unicode_version":"6.0"},{"emoji":"🇽🇰","aliases":["kosovo"],"tags":[],"category":"Flags","description":"flag: Kosovo","unicode_version":"6.0"},{"emoji":"🇾🇪","aliases":["yemen"],"tags":[],"category":"Flags","description":"flag: Yemen","unicode_version":"6.0"},{"emoji":"🇾🇹","aliases":["mayotte"],"tags":[],"category":"Flags","description":"flag: Mayotte","unicode_version":"6.0"},{"emoji":"🇿🇦","aliases":["south_africa"],"tags":[],"category":"Flags","description":"flag: South Africa","unicode_version":"6.0"},{"emoji":"🇿🇲","aliases":["zambia"],"tags":[],"category":"Flags","description":"flag: Zambia","unicode_version":"6.0"},{"emoji":"🇿🇼","aliases":["zimbabwe"],"tags":[],"category":"Flags","description":"flag: Zimbabwe","unicode_version":"6.0"},{"emoji":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","aliases":["england"],"tags":[],"category":"Flags","description":"flag: England","unicode_version":"11.0"},{"emoji":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","aliases":["scotland"],"tags":[],"category":"Flags","description":"flag: Scotland","unicode_version":"11.0"},{"emoji":"🏴󠁧󠁢󠁷󠁬󠁳󠁿","aliases":["wales"],"tags":[],"category":"Flags","description":"flag: Wales","unicode_version":"11.0"}] +export const rawEmojis = [ + { + emoji: "😀", + aliases: ["grinning"], + tags: ["smile", "happy"], + category: "Smileys & Emotion", + description: "grinning face", + unicode_version: "6.1", + }, + { + emoji: "😃", + aliases: ["smiley"], + tags: ["happy", "joy", "haha"], + category: "Smileys & Emotion", + description: "grinning face with big eyes", + unicode_version: "6.0", + }, + { + emoji: "😄", + aliases: ["smile"], + tags: ["happy", "joy", "laugh", "pleased"], + category: "Smileys & Emotion", + description: "grinning face with smiling eyes", + unicode_version: "6.0", + }, + { + emoji: "😁", + aliases: ["grin"], + tags: [], + category: "Smileys & Emotion", + description: "beaming face with smiling eyes", + unicode_version: "6.0", + }, + { + emoji: "😆", + aliases: ["laughing", "satisfied"], + tags: ["happy", "haha"], + category: "Smileys & Emotion", + description: "grinning squinting face", + unicode_version: "6.0", + }, + { + emoji: "😅", + aliases: ["sweat_smile"], + tags: ["hot"], + category: "Smileys & Emotion", + description: "grinning face with sweat", + unicode_version: "6.0", + }, + { + emoji: "🤣", + aliases: ["rofl"], + tags: ["lol", "laughing"], + category: "Smileys & Emotion", + description: "rolling on the floor laughing", + unicode_version: "9.0", + }, + { + emoji: "😂", + aliases: ["joy"], + tags: ["tears"], + category: "Smileys & Emotion", + description: "face with tears of joy", + unicode_version: "6.0", + }, + { + emoji: "🙂", + aliases: ["slightly_smiling_face"], + tags: [], + category: "Smileys & Emotion", + description: "slightly smiling face", + unicode_version: "7.0", + }, + { + emoji: "🙃", + aliases: ["upside_down_face"], + tags: [], + category: "Smileys & Emotion", + description: "upside-down face", + unicode_version: "8.0", + }, + { + emoji: "😉", + aliases: ["wink"], + tags: ["flirt"], + category: "Smileys & Emotion", + description: "winking face", + unicode_version: "6.0", + }, + { + emoji: "😊", + aliases: ["blush"], + tags: ["proud"], + category: "Smileys & Emotion", + description: "smiling face with smiling eyes", + unicode_version: "6.0", + }, + { + emoji: "😇", + aliases: ["innocent"], + tags: ["angel"], + category: "Smileys & Emotion", + description: "smiling face with halo", + unicode_version: "6.0", + }, + { + emoji: "🥰", + aliases: ["smiling_face_with_three_hearts"], + tags: ["love"], + category: "Smileys & Emotion", + description: "smiling face with hearts", + unicode_version: "11.0", + }, + { + emoji: "😍", + aliases: ["heart_eyes"], + tags: ["love", "crush"], + category: "Smileys & Emotion", + description: "smiling face with heart-eyes", + unicode_version: "6.0", + }, + { + emoji: "🤩", + aliases: ["star_struck"], + tags: ["eyes"], + category: "Smileys & Emotion", + description: "star-struck", + unicode_version: "11.0", + }, + { + emoji: "😘", + aliases: ["kissing_heart"], + tags: ["flirt"], + category: "Smileys & Emotion", + description: "face blowing a kiss", + unicode_version: "6.0", + }, + { + emoji: "😗", + aliases: ["kissing"], + tags: [], + category: "Smileys & Emotion", + description: "kissing face", + unicode_version: "6.1", + }, + { + emoji: "☺️", + aliases: ["relaxed"], + tags: ["blush", "pleased"], + category: "Smileys & Emotion", + description: "smiling face", + unicode_version: "", + }, + { + emoji: "😚", + aliases: ["kissing_closed_eyes"], + tags: [], + category: "Smileys & Emotion", + description: "kissing face with closed eyes", + unicode_version: "6.0", + }, + { + emoji: "😙", + aliases: ["kissing_smiling_eyes"], + tags: [], + category: "Smileys & Emotion", + description: "kissing face with smiling eyes", + unicode_version: "6.1", + }, + { + emoji: "🥲", + aliases: ["smiling_face_with_tear"], + tags: [], + category: "Smileys & Emotion", + description: "smiling face with tear", + unicode_version: "13.0", + }, + { + emoji: "😋", + aliases: ["yum"], + tags: ["tongue", "lick"], + category: "Smileys & Emotion", + description: "face savoring food", + unicode_version: "6.0", + }, + { + emoji: "😛", + aliases: ["stuck_out_tongue"], + tags: [], + category: "Smileys & Emotion", + description: "face with tongue", + unicode_version: "6.1", + }, + { + emoji: "😜", + aliases: ["stuck_out_tongue_winking_eye"], + tags: ["prank", "silly"], + category: "Smileys & Emotion", + description: "winking face with tongue", + unicode_version: "6.0", + }, + { + emoji: "🤪", + aliases: ["zany_face"], + tags: ["goofy", "wacky"], + category: "Smileys & Emotion", + description: "zany face", + unicode_version: "11.0", + }, + { + emoji: "😝", + aliases: ["stuck_out_tongue_closed_eyes"], + tags: ["prank"], + category: "Smileys & Emotion", + description: "squinting face with tongue", + unicode_version: "6.0", + }, + { + emoji: "🤑", + aliases: ["money_mouth_face"], + tags: ["rich"], + category: "Smileys & Emotion", + description: "money-mouth face", + unicode_version: "8.0", + }, + { + emoji: "🤗", + aliases: ["hugs"], + tags: [], + category: "Smileys & Emotion", + description: "hugging face", + unicode_version: "8.0", + }, + { + emoji: "🤭", + aliases: ["hand_over_mouth"], + tags: ["quiet", "whoops"], + category: "Smileys & Emotion", + description: "face with hand over mouth", + unicode_version: "11.0", + }, + { + emoji: "🤫", + aliases: ["shushing_face"], + tags: ["silence", "quiet"], + category: "Smileys & Emotion", + description: "shushing face", + unicode_version: "11.0", + }, + { + emoji: "🤔", + aliases: ["thinking"], + tags: [], + category: "Smileys & Emotion", + description: "thinking face", + unicode_version: "8.0", + }, + { + emoji: "🤐", + aliases: ["zipper_mouth_face"], + tags: ["silence", "hush"], + category: "Smileys & Emotion", + description: "zipper-mouth face", + unicode_version: "8.0", + }, + { + emoji: "🤨", + aliases: ["raised_eyebrow"], + tags: ["suspicious"], + category: "Smileys & Emotion", + description: "face with raised eyebrow", + unicode_version: "11.0", + }, + { + emoji: "😐", + aliases: ["neutral_face"], + tags: ["meh"], + category: "Smileys & Emotion", + description: "neutral face", + unicode_version: "6.0", + }, + { + emoji: "😑", + aliases: ["expressionless"], + tags: [], + category: "Smileys & Emotion", + description: "expressionless face", + unicode_version: "6.1", + }, + { + emoji: "😶", + aliases: ["no_mouth"], + tags: ["mute", "silence"], + category: "Smileys & Emotion", + description: "face without mouth", + unicode_version: "6.0", + }, + { + emoji: "😶‍🌫️", + aliases: ["face_in_clouds"], + tags: [], + category: "Smileys & Emotion", + description: "face in clouds", + unicode_version: "13.1", + }, + { + emoji: "😏", + aliases: ["smirk"], + tags: ["smug"], + category: "Smileys & Emotion", + description: "smirking face", + unicode_version: "6.0", + }, + { + emoji: "😒", + aliases: ["unamused"], + tags: ["meh"], + category: "Smileys & Emotion", + description: "unamused face", + unicode_version: "6.0", + }, + { + emoji: "🙄", + aliases: ["roll_eyes"], + tags: [], + category: "Smileys & Emotion", + description: "face with rolling eyes", + unicode_version: "8.0", + }, + { + emoji: "😬", + aliases: ["grimacing"], + tags: [], + category: "Smileys & Emotion", + description: "grimacing face", + unicode_version: "6.1", + }, + { + emoji: "😮‍💨", + aliases: ["face_exhaling"], + tags: [], + category: "Smileys & Emotion", + description: "face exhaling", + unicode_version: "13.1", + }, + { + emoji: "🤥", + aliases: ["lying_face"], + tags: ["liar"], + category: "Smileys & Emotion", + description: "lying face", + unicode_version: "9.0", + }, + { + emoji: "😌", + aliases: ["relieved"], + tags: ["whew"], + category: "Smileys & Emotion", + description: "relieved face", + unicode_version: "6.0", + }, + { + emoji: "😔", + aliases: ["pensive"], + tags: [], + category: "Smileys & Emotion", + description: "pensive face", + unicode_version: "6.0", + }, + { + emoji: "😪", + aliases: ["sleepy"], + tags: ["tired"], + category: "Smileys & Emotion", + description: "sleepy face", + unicode_version: "6.0", + }, + { + emoji: "🤤", + aliases: ["drooling_face"], + tags: [], + category: "Smileys & Emotion", + description: "drooling face", + unicode_version: "9.0", + }, + { + emoji: "😴", + aliases: ["sleeping"], + tags: ["zzz"], + category: "Smileys & Emotion", + description: "sleeping face", + unicode_version: "6.1", + }, + { + emoji: "😷", + aliases: ["mask"], + tags: ["sick", "ill"], + category: "Smileys & Emotion", + description: "face with medical mask", + unicode_version: "6.0", + }, + { + emoji: "🤒", + aliases: ["face_with_thermometer"], + tags: ["sick"], + category: "Smileys & Emotion", + description: "face with thermometer", + unicode_version: "8.0", + }, + { + emoji: "🤕", + aliases: ["face_with_head_bandage"], + tags: ["hurt"], + category: "Smileys & Emotion", + description: "face with head-bandage", + unicode_version: "8.0", + }, + { + emoji: "🤢", + aliases: ["nauseated_face"], + tags: ["sick", "barf", "disgusted"], + category: "Smileys & Emotion", + description: "nauseated face", + unicode_version: "9.0", + }, + { + emoji: "🤮", + aliases: ["vomiting_face"], + tags: ["barf", "sick"], + category: "Smileys & Emotion", + description: "face vomiting", + unicode_version: "11.0", + }, + { + emoji: "🤧", + aliases: ["sneezing_face"], + tags: ["achoo", "sick"], + category: "Smileys & Emotion", + description: "sneezing face", + unicode_version: "9.0", + }, + { + emoji: "🥵", + aliases: ["hot_face"], + tags: ["heat", "sweating"], + category: "Smileys & Emotion", + description: "hot face", + unicode_version: "11.0", + }, + { + emoji: "🥶", + aliases: ["cold_face"], + tags: ["freezing", "ice"], + category: "Smileys & Emotion", + description: "cold face", + unicode_version: "11.0", + }, + { + emoji: "🥴", + aliases: ["woozy_face"], + tags: ["groggy"], + category: "Smileys & Emotion", + description: "woozy face", + unicode_version: "11.0", + }, + { + emoji: "😵", + aliases: ["dizzy_face"], + tags: [], + category: "Smileys & Emotion", + description: "knocked-out face", + unicode_version: "6.0", + }, + { + emoji: "😵‍💫", + aliases: ["face_with_spiral_eyes"], + tags: [], + category: "Smileys & Emotion", + description: "face with spiral eyes", + unicode_version: "13.1", + }, + { + emoji: "🤯", + aliases: ["exploding_head"], + tags: ["mind", "blown"], + category: "Smileys & Emotion", + description: "exploding head", + unicode_version: "11.0", + }, + { + emoji: "🤠", + aliases: ["cowboy_hat_face"], + tags: [], + category: "Smileys & Emotion", + description: "cowboy hat face", + unicode_version: "9.0", + }, + { + emoji: "🥳", + aliases: ["partying_face"], + tags: ["celebration", "birthday"], + category: "Smileys & Emotion", + description: "partying face", + unicode_version: "11.0", + }, + { + emoji: "🥸", + aliases: ["disguised_face"], + tags: [], + category: "Smileys & Emotion", + description: "disguised face", + unicode_version: "13.0", + }, + { + emoji: "😎", + aliases: ["sunglasses"], + tags: ["cool"], + category: "Smileys & Emotion", + description: "smiling face with sunglasses", + unicode_version: "6.0", + }, + { + emoji: "🤓", + aliases: ["nerd_face"], + tags: ["geek", "glasses"], + category: "Smileys & Emotion", + description: "nerd face", + unicode_version: "8.0", + }, + { + emoji: "🧐", + aliases: ["monocle_face"], + tags: [], + category: "Smileys & Emotion", + description: "face with monocle", + unicode_version: "11.0", + }, + { + emoji: "😕", + aliases: ["confused"], + tags: [], + category: "Smileys & Emotion", + description: "confused face", + unicode_version: "6.1", + }, + { + emoji: "😟", + aliases: ["worried"], + tags: ["nervous"], + category: "Smileys & Emotion", + description: "worried face", + unicode_version: "6.1", + }, + { + emoji: "🙁", + aliases: ["slightly_frowning_face"], + tags: [], + category: "Smileys & Emotion", + description: "slightly frowning face", + unicode_version: "7.0", + }, + { + emoji: "☹️", + aliases: ["frowning_face"], + tags: [], + category: "Smileys & Emotion", + description: "frowning face", + unicode_version: "", + }, + { + emoji: "😮", + aliases: ["open_mouth"], + tags: ["surprise", "impressed", "wow"], + category: "Smileys & Emotion", + description: "face with open mouth", + unicode_version: "6.1", + }, + { + emoji: "😯", + aliases: ["hushed"], + tags: ["silence", "speechless"], + category: "Smileys & Emotion", + description: "hushed face", + unicode_version: "6.1", + }, + { + emoji: "😲", + aliases: ["astonished"], + tags: ["amazed", "gasp"], + category: "Smileys & Emotion", + description: "astonished face", + unicode_version: "6.0", + }, + { + emoji: "😳", + aliases: ["flushed"], + tags: [], + category: "Smileys & Emotion", + description: "flushed face", + unicode_version: "6.0", + }, + { + emoji: "🥺", + aliases: ["pleading_face"], + tags: ["puppy", "eyes"], + category: "Smileys & Emotion", + description: "pleading face", + unicode_version: "11.0", + }, + { + emoji: "😦", + aliases: ["frowning"], + tags: [], + category: "Smileys & Emotion", + description: "frowning face with open mouth", + unicode_version: "6.1", + }, + { + emoji: "😧", + aliases: ["anguished"], + tags: ["stunned"], + category: "Smileys & Emotion", + description: "anguished face", + unicode_version: "6.1", + }, + { + emoji: "😨", + aliases: ["fearful"], + tags: ["scared", "shocked", "oops"], + category: "Smileys & Emotion", + description: "fearful face", + unicode_version: "6.0", + }, + { + emoji: "😰", + aliases: ["cold_sweat"], + tags: ["nervous"], + category: "Smileys & Emotion", + description: "anxious face with sweat", + unicode_version: "6.0", + }, + { + emoji: "😥", + aliases: ["disappointed_relieved"], + tags: ["phew", "sweat", "nervous"], + category: "Smileys & Emotion", + description: "sad but relieved face", + unicode_version: "6.0", + }, + { + emoji: "😢", + aliases: ["cry"], + tags: ["sad", "tear"], + category: "Smileys & Emotion", + description: "crying face", + unicode_version: "6.0", + }, + { + emoji: "😭", + aliases: ["sob"], + tags: ["sad", "cry", "bawling"], + category: "Smileys & Emotion", + description: "loudly crying face", + unicode_version: "6.0", + }, + { + emoji: "😱", + aliases: ["scream"], + tags: ["horror", "shocked"], + category: "Smileys & Emotion", + description: "face screaming in fear", + unicode_version: "6.0", + }, + { + emoji: "😖", + aliases: ["confounded"], + tags: [], + category: "Smileys & Emotion", + description: "confounded face", + unicode_version: "6.0", + }, + { + emoji: "😣", + aliases: ["persevere"], + tags: ["struggling"], + category: "Smileys & Emotion", + description: "persevering face", + unicode_version: "6.0", + }, + { + emoji: "😞", + aliases: ["disappointed"], + tags: ["sad"], + category: "Smileys & Emotion", + description: "disappointed face", + unicode_version: "6.0", + }, + { + emoji: "😓", + aliases: ["sweat"], + tags: [], + category: "Smileys & Emotion", + description: "downcast face with sweat", + unicode_version: "6.0", + }, + { + emoji: "😩", + aliases: ["weary"], + tags: ["tired"], + category: "Smileys & Emotion", + description: "weary face", + unicode_version: "6.0", + }, + { + emoji: "😫", + aliases: ["tired_face"], + tags: ["upset", "whine"], + category: "Smileys & Emotion", + description: "tired face", + unicode_version: "6.0", + }, + { + emoji: "🥱", + aliases: ["yawning_face"], + tags: [], + category: "Smileys & Emotion", + description: "yawning face", + unicode_version: "12.0", + }, + { + emoji: "😤", + aliases: ["triumph"], + tags: ["smug"], + category: "Smileys & Emotion", + description: "face with steam from nose", + unicode_version: "6.0", + }, + { + emoji: "😡", + aliases: ["rage", "pout"], + tags: ["angry"], + category: "Smileys & Emotion", + description: "pouting face", + unicode_version: "6.0", + }, + { + emoji: "😠", + aliases: ["angry"], + tags: ["mad", "annoyed"], + category: "Smileys & Emotion", + description: "angry face", + unicode_version: "6.0", + }, + { + emoji: "🤬", + aliases: ["cursing_face"], + tags: ["foul"], + category: "Smileys & Emotion", + description: "face with symbols on mouth", + unicode_version: "11.0", + }, + { + emoji: "😈", + aliases: ["smiling_imp"], + tags: ["devil", "evil", "horns"], + category: "Smileys & Emotion", + description: "smiling face with horns", + unicode_version: "6.0", + }, + { + emoji: "👿", + aliases: ["imp"], + tags: ["angry", "devil", "evil", "horns"], + category: "Smileys & Emotion", + description: "angry face with horns", + unicode_version: "6.0", + }, + { + emoji: "💀", + aliases: ["skull"], + tags: ["dead", "danger", "poison"], + category: "Smileys & Emotion", + description: "skull", + unicode_version: "6.0", + }, + { + emoji: "☠️", + aliases: ["skull_and_crossbones"], + tags: ["danger", "pirate"], + category: "Smileys & Emotion", + description: "skull and crossbones", + unicode_version: "", + }, + { + emoji: "💩", + aliases: ["hankey", "poop", "shit"], + tags: ["crap"], + category: "Smileys & Emotion", + description: "pile of poo", + unicode_version: "6.0", + }, + { + emoji: "🤡", + aliases: ["clown_face"], + tags: [], + category: "Smileys & Emotion", + description: "clown face", + unicode_version: "9.0", + }, + { + emoji: "👹", + aliases: ["japanese_ogre"], + tags: ["monster"], + category: "Smileys & Emotion", + description: "ogre", + unicode_version: "6.0", + }, + { + emoji: "👺", + aliases: ["japanese_goblin"], + tags: [], + category: "Smileys & Emotion", + description: "goblin", + unicode_version: "6.0", + }, + { + emoji: "👻", + aliases: ["ghost"], + tags: ["halloween"], + category: "Smileys & Emotion", + description: "ghost", + unicode_version: "6.0", + }, + { + emoji: "👽", + aliases: ["alien"], + tags: ["ufo"], + category: "Smileys & Emotion", + description: "alien", + unicode_version: "6.0", + }, + { + emoji: "👾", + aliases: ["space_invader"], + tags: ["game", "retro"], + category: "Smileys & Emotion", + description: "alien monster", + unicode_version: "6.0", + }, + { + emoji: "🤖", + aliases: ["robot"], + tags: [], + category: "Smileys & Emotion", + description: "robot", + unicode_version: "8.0", + }, + { + emoji: "😺", + aliases: ["smiley_cat"], + tags: [], + category: "Smileys & Emotion", + description: "grinning cat", + unicode_version: "6.0", + }, + { + emoji: "😸", + aliases: ["smile_cat"], + tags: [], + category: "Smileys & Emotion", + description: "grinning cat with smiling eyes", + unicode_version: "6.0", + }, + { + emoji: "😹", + aliases: ["joy_cat"], + tags: [], + category: "Smileys & Emotion", + description: "cat with tears of joy", + unicode_version: "6.0", + }, + { + emoji: "😻", + aliases: ["heart_eyes_cat"], + tags: [], + category: "Smileys & Emotion", + description: "smiling cat with heart-eyes", + unicode_version: "6.0", + }, + { + emoji: "😼", + aliases: ["smirk_cat"], + tags: [], + category: "Smileys & Emotion", + description: "cat with wry smile", + unicode_version: "6.0", + }, + { + emoji: "😽", + aliases: ["kissing_cat"], + tags: [], + category: "Smileys & Emotion", + description: "kissing cat", + unicode_version: "6.0", + }, + { + emoji: "🙀", + aliases: ["scream_cat"], + tags: ["horror"], + category: "Smileys & Emotion", + description: "weary cat", + unicode_version: "6.0", + }, + { + emoji: "😿", + aliases: ["crying_cat_face"], + tags: ["sad", "tear"], + category: "Smileys & Emotion", + description: "crying cat", + unicode_version: "6.0", + }, + { + emoji: "😾", + aliases: ["pouting_cat"], + tags: [], + category: "Smileys & Emotion", + description: "pouting cat", + unicode_version: "6.0", + }, + { + emoji: "🙈", + aliases: ["see_no_evil"], + tags: ["monkey", "blind", "ignore"], + category: "Smileys & Emotion", + description: "see-no-evil monkey", + unicode_version: "6.0", + }, + { + emoji: "🙉", + aliases: ["hear_no_evil"], + tags: ["monkey", "deaf"], + category: "Smileys & Emotion", + description: "hear-no-evil monkey", + unicode_version: "6.0", + }, + { + emoji: "🙊", + aliases: ["speak_no_evil"], + tags: ["monkey", "mute", "hush"], + category: "Smileys & Emotion", + description: "speak-no-evil monkey", + unicode_version: "6.0", + }, + { + emoji: "💋", + aliases: ["kiss"], + tags: ["lipstick"], + category: "Smileys & Emotion", + description: "kiss mark", + unicode_version: "6.0", + }, + { + emoji: "💌", + aliases: ["love_letter"], + tags: ["email", "envelope"], + category: "Smileys & Emotion", + description: "love letter", + unicode_version: "6.0", + }, + { + emoji: "💘", + aliases: ["cupid"], + tags: ["love", "heart"], + category: "Smileys & Emotion", + description: "heart with arrow", + unicode_version: "6.0", + }, + { + emoji: "💝", + aliases: ["gift_heart"], + tags: ["chocolates"], + category: "Smileys & Emotion", + description: "heart with ribbon", + unicode_version: "6.0", + }, + { + emoji: "💖", + aliases: ["sparkling_heart"], + tags: [], + category: "Smileys & Emotion", + description: "sparkling heart", + unicode_version: "6.0", + }, + { + emoji: "💗", + aliases: ["heartpulse"], + tags: [], + category: "Smileys & Emotion", + description: "growing heart", + unicode_version: "6.0", + }, + { + emoji: "💓", + aliases: ["heartbeat"], + tags: [], + category: "Smileys & Emotion", + description: "beating heart", + unicode_version: "6.0", + }, + { + emoji: "💞", + aliases: ["revolving_hearts"], + tags: [], + category: "Smileys & Emotion", + description: "revolving hearts", + unicode_version: "6.0", + }, + { + emoji: "💕", + aliases: ["two_hearts"], + tags: [], + category: "Smileys & Emotion", + description: "two hearts", + unicode_version: "6.0", + }, + { + emoji: "💟", + aliases: ["heart_decoration"], + tags: [], + category: "Smileys & Emotion", + description: "heart decoration", + unicode_version: "6.0", + }, + { + emoji: "❣️", + aliases: ["heavy_heart_exclamation"], + tags: [], + category: "Smileys & Emotion", + description: "heart exclamation", + unicode_version: "", + }, + { + emoji: "💔", + aliases: ["broken_heart"], + tags: [], + category: "Smileys & Emotion", + description: "broken heart", + unicode_version: "6.0", + }, + { + emoji: "❤️‍🔥", + aliases: ["heart_on_fire"], + tags: [], + category: "Smileys & Emotion", + description: "heart on fire", + unicode_version: "13.1", + }, + { + emoji: "❤️‍🩹", + aliases: ["mending_heart"], + tags: [], + category: "Smileys & Emotion", + description: "mending heart", + unicode_version: "13.1", + }, + { + emoji: "❤️", + aliases: ["heart"], + tags: ["love"], + category: "Smileys & Emotion", + description: "red heart", + unicode_version: "", + }, + { + emoji: "🧡", + aliases: ["orange_heart"], + tags: [], + category: "Smileys & Emotion", + description: "orange heart", + unicode_version: "11.0", + }, + { + emoji: "💛", + aliases: ["yellow_heart"], + tags: [], + category: "Smileys & Emotion", + description: "yellow heart", + unicode_version: "6.0", + }, + { + emoji: "💚", + aliases: ["green_heart"], + tags: [], + category: "Smileys & Emotion", + description: "green heart", + unicode_version: "6.0", + }, + { + emoji: "💙", + aliases: ["blue_heart"], + tags: [], + category: "Smileys & Emotion", + description: "blue heart", + unicode_version: "6.0", + }, + { + emoji: "💜", + aliases: ["purple_heart"], + tags: [], + category: "Smileys & Emotion", + description: "purple heart", + unicode_version: "6.0", + }, + { + emoji: "🤎", + aliases: ["brown_heart"], + tags: [], + category: "Smileys & Emotion", + description: "brown heart", + unicode_version: "12.0", + }, + { + emoji: "🖤", + aliases: ["black_heart"], + tags: [], + category: "Smileys & Emotion", + description: "black heart", + unicode_version: "9.0", + }, + { + emoji: "🤍", + aliases: ["white_heart"], + tags: [], + category: "Smileys & Emotion", + description: "white heart", + unicode_version: "12.0", + }, + { + emoji: "💯", + aliases: ["100"], + tags: ["score", "perfect"], + category: "Smileys & Emotion", + description: "hundred points", + unicode_version: "6.0", + }, + { + emoji: "💢", + aliases: ["anger"], + tags: ["angry"], + category: "Smileys & Emotion", + description: "anger symbol", + unicode_version: "6.0", + }, + { + emoji: "💥", + aliases: ["boom", "collision"], + tags: ["explode"], + category: "Smileys & Emotion", + description: "collision", + unicode_version: "6.0", + }, + { + emoji: "💫", + aliases: ["dizzy"], + tags: ["star"], + category: "Smileys & Emotion", + description: "dizzy", + unicode_version: "6.0", + }, + { + emoji: "💦", + aliases: ["sweat_drops"], + tags: ["water", "workout"], + category: "Smileys & Emotion", + description: "sweat droplets", + unicode_version: "6.0", + }, + { + emoji: "💨", + aliases: ["dash"], + tags: ["wind", "blow", "fast"], + category: "Smileys & Emotion", + description: "dashing away", + unicode_version: "6.0", + }, + { + emoji: "🕳️", + aliases: ["hole"], + tags: [], + category: "Smileys & Emotion", + description: "hole", + unicode_version: "7.0", + }, + { + emoji: "💣", + aliases: ["bomb"], + tags: ["boom"], + category: "Smileys & Emotion", + description: "bomb", + unicode_version: "6.0", + }, + { + emoji: "💬", + aliases: ["speech_balloon"], + tags: ["comment"], + category: "Smileys & Emotion", + description: "speech balloon", + unicode_version: "6.0", + }, + { + emoji: "👁️‍🗨️", + aliases: ["eye_speech_bubble"], + tags: [], + category: "Smileys & Emotion", + description: "eye in speech bubble", + unicode_version: "11.0", + }, + { + emoji: "🗨️", + aliases: ["left_speech_bubble"], + tags: [], + category: "Smileys & Emotion", + description: "left speech bubble", + unicode_version: "11.0", + }, + { + emoji: "🗯️", + aliases: ["right_anger_bubble"], + tags: [], + category: "Smileys & Emotion", + description: "right anger bubble", + unicode_version: "7.0", + }, + { + emoji: "💭", + aliases: ["thought_balloon"], + tags: ["thinking"], + category: "Smileys & Emotion", + description: "thought balloon", + unicode_version: "6.0", + }, + { + emoji: "💤", + aliases: ["zzz"], + tags: ["sleeping"], + category: "Smileys & Emotion", + description: "zzz", + unicode_version: "6.0", + }, + { + emoji: "👋", + aliases: ["wave"], + tags: ["goodbye"], + category: "People & Body", + description: "waving hand", + unicode_version: "6.0", + }, + { + emoji: "🤚", + aliases: ["raised_back_of_hand"], + tags: [], + category: "People & Body", + description: "raised back of hand", + unicode_version: "9.0", + }, + { + emoji: "🖐️", + aliases: ["raised_hand_with_fingers_splayed"], + tags: [], + category: "People & Body", + description: "hand with fingers splayed", + unicode_version: "7.0", + }, + { + emoji: "✋", + aliases: ["hand", "raised_hand"], + tags: ["highfive", "stop"], + category: "People & Body", + description: "raised hand", + unicode_version: "6.0", + }, + { + emoji: "🖖", + aliases: ["vulcan_salute"], + tags: ["prosper", "spock"], + category: "People & Body", + description: "vulcan salute", + unicode_version: "7.0", + }, + { + emoji: "👌", + aliases: ["ok_hand"], + tags: [], + category: "People & Body", + description: "OK hand", + unicode_version: "6.0", + }, + { + emoji: "🤌", + aliases: ["pinched_fingers"], + tags: [], + category: "People & Body", + description: "pinched fingers", + unicode_version: "13.0", + }, + { + emoji: "🤏", + aliases: ["pinching_hand"], + tags: [], + category: "People & Body", + description: "pinching hand", + unicode_version: "12.0", + }, + { + emoji: "✌️", + aliases: ["v"], + tags: ["victory", "peace"], + category: "People & Body", + description: "victory hand", + unicode_version: "", + }, + { + emoji: "🤞", + aliases: ["crossed_fingers"], + tags: ["luck", "hopeful"], + category: "People & Body", + description: "crossed fingers", + unicode_version: "9.0", + }, + { + emoji: "🤟", + aliases: ["love_you_gesture"], + tags: [], + category: "People & Body", + description: "love-you gesture", + unicode_version: "11.0", + }, + { + emoji: "🤘", + aliases: ["metal"], + tags: [], + category: "People & Body", + description: "sign of the horns", + unicode_version: "8.0", + }, + { + emoji: "🤙", + aliases: ["call_me_hand"], + tags: [], + category: "People & Body", + description: "call me hand", + unicode_version: "9.0", + }, + { + emoji: "👈", + aliases: ["point_left"], + tags: [], + category: "People & Body", + description: "backhand index pointing left", + unicode_version: "6.0", + }, + { + emoji: "👉", + aliases: ["point_right"], + tags: [], + category: "People & Body", + description: "backhand index pointing right", + unicode_version: "6.0", + }, + { + emoji: "👆", + aliases: ["point_up_2"], + tags: [], + category: "People & Body", + description: "backhand index pointing up", + unicode_version: "6.0", + }, + { + emoji: "🖕", + aliases: ["middle_finger", "fu"], + tags: [], + category: "People & Body", + description: "middle finger", + unicode_version: "7.0", + }, + { + emoji: "👇", + aliases: ["point_down"], + tags: [], + category: "People & Body", + description: "backhand index pointing down", + unicode_version: "6.0", + }, + { + emoji: "☝️", + aliases: ["point_up"], + tags: [], + category: "People & Body", + description: "index pointing up", + unicode_version: "", + }, + { + emoji: "👍", + aliases: ["+1", "thumbsup"], + tags: ["approve", "ok"], + category: "People & Body", + description: "thumbs up", + unicode_version: "6.0", + }, + { + emoji: "👎", + aliases: ["-1", "thumbsdown"], + tags: ["disapprove", "bury"], + category: "People & Body", + description: "thumbs down", + unicode_version: "6.0", + }, + { + emoji: "✊", + aliases: ["fist_raised", "fist"], + tags: ["power"], + category: "People & Body", + description: "raised fist", + unicode_version: "6.0", + }, + { + emoji: "👊", + aliases: ["fist_oncoming", "facepunch", "punch"], + tags: ["attack"], + category: "People & Body", + description: "oncoming fist", + unicode_version: "6.0", + }, + { + emoji: "🤛", + aliases: ["fist_left"], + tags: [], + category: "People & Body", + description: "left-facing fist", + unicode_version: "9.0", + }, + { + emoji: "🤜", + aliases: ["fist_right"], + tags: [], + category: "People & Body", + description: "right-facing fist", + unicode_version: "9.0", + }, + { + emoji: "👏", + aliases: ["clap"], + tags: ["praise", "applause"], + category: "People & Body", + description: "clapping hands", + unicode_version: "6.0", + }, + { + emoji: "🙌", + aliases: ["raised_hands"], + tags: ["hooray"], + category: "People & Body", + description: "raising hands", + unicode_version: "6.0", + }, + { + emoji: "👐", + aliases: ["open_hands"], + tags: [], + category: "People & Body", + description: "open hands", + unicode_version: "6.0", + }, + { + emoji: "🤲", + aliases: ["palms_up_together"], + tags: [], + category: "People & Body", + description: "palms up together", + unicode_version: "11.0", + }, + { + emoji: "🤝", + aliases: ["handshake"], + tags: ["deal"], + category: "People & Body", + description: "handshake", + unicode_version: "9.0", + }, + { + emoji: "🙏", + aliases: ["pray"], + tags: ["please", "hope", "wish"], + category: "People & Body", + description: "folded hands", + unicode_version: "6.0", + }, + { + emoji: "✍️", + aliases: ["writing_hand"], + tags: [], + category: "People & Body", + description: "writing hand", + unicode_version: "", + }, + { + emoji: "💅", + aliases: ["nail_care"], + tags: ["beauty", "manicure"], + category: "People & Body", + description: "nail polish", + unicode_version: "6.0", + }, + { + emoji: "🤳", + aliases: ["selfie"], + tags: [], + category: "People & Body", + description: "selfie", + unicode_version: "9.0", + }, + { + emoji: "💪", + aliases: ["muscle"], + tags: ["flex", "bicep", "strong", "workout"], + category: "People & Body", + description: "flexed biceps", + unicode_version: "6.0", + }, + { + emoji: "🦾", + aliases: ["mechanical_arm"], + tags: [], + category: "People & Body", + description: "mechanical arm", + unicode_version: "12.0", + }, + { + emoji: "🦿", + aliases: ["mechanical_leg"], + tags: [], + category: "People & Body", + description: "mechanical leg", + unicode_version: "12.0", + }, + { + emoji: "🦵", + aliases: ["leg"], + tags: [], + category: "People & Body", + description: "leg", + unicode_version: "11.0", + }, + { + emoji: "🦶", + aliases: ["foot"], + tags: [], + category: "People & Body", + description: "foot", + unicode_version: "11.0", + }, + { + emoji: "👂", + aliases: ["ear"], + tags: ["hear", "sound", "listen"], + category: "People & Body", + description: "ear", + unicode_version: "6.0", + }, + { + emoji: "🦻", + aliases: ["ear_with_hearing_aid"], + tags: [], + category: "People & Body", + description: "ear with hearing aid", + unicode_version: "12.0", + }, + { + emoji: "👃", + aliases: ["nose"], + tags: ["smell"], + category: "People & Body", + description: "nose", + unicode_version: "6.0", + }, + { + emoji: "🧠", + aliases: ["brain"], + tags: [], + category: "People & Body", + description: "brain", + unicode_version: "11.0", + }, + { + emoji: "🫀", + aliases: ["anatomical_heart"], + tags: [], + category: "People & Body", + description: "anatomical heart", + unicode_version: "13.0", + }, + { + emoji: "🫁", + aliases: ["lungs"], + tags: [], + category: "People & Body", + description: "lungs", + unicode_version: "13.0", + }, + { + emoji: "🦷", + aliases: ["tooth"], + tags: [], + category: "People & Body", + description: "tooth", + unicode_version: "11.0", + }, + { + emoji: "🦴", + aliases: ["bone"], + tags: [], + category: "People & Body", + description: "bone", + unicode_version: "11.0", + }, + { + emoji: "👀", + aliases: ["eyes"], + tags: ["look", "see", "watch"], + category: "People & Body", + description: "eyes", + unicode_version: "6.0", + }, + { + emoji: "👁️", + aliases: ["eye"], + tags: [], + category: "People & Body", + description: "eye", + unicode_version: "7.0", + }, + { + emoji: "👅", + aliases: ["tongue"], + tags: ["taste"], + category: "People & Body", + description: "tongue", + unicode_version: "6.0", + }, + { + emoji: "👄", + aliases: ["lips"], + tags: ["kiss"], + category: "People & Body", + description: "mouth", + unicode_version: "6.0", + }, + { + emoji: "👶", + aliases: ["baby"], + tags: ["child", "newborn"], + category: "People & Body", + description: "baby", + unicode_version: "6.0", + }, + { + emoji: "🧒", + aliases: ["child"], + tags: [], + category: "People & Body", + description: "child", + unicode_version: "11.0", + }, + { + emoji: "👦", + aliases: ["boy"], + tags: ["child"], + category: "People & Body", + description: "boy", + unicode_version: "6.0", + }, + { + emoji: "👧", + aliases: ["girl"], + tags: ["child"], + category: "People & Body", + description: "girl", + unicode_version: "6.0", + }, + { + emoji: "🧑", + aliases: ["adult"], + tags: [], + category: "People & Body", + description: "person", + unicode_version: "11.0", + }, + { + emoji: "👱", + aliases: ["blond_haired_person"], + tags: [], + category: "People & Body", + description: "person: blond hair", + unicode_version: "6.0", + }, + { + emoji: "👨", + aliases: ["man"], + tags: ["mustache", "father", "dad"], + category: "People & Body", + description: "man", + unicode_version: "6.0", + }, + { + emoji: "🧔", + aliases: ["bearded_person"], + tags: [], + category: "People & Body", + description: "person: beard", + unicode_version: "11.0", + }, + { + emoji: "🧔‍♂️", + aliases: ["man_beard"], + tags: [], + category: "People & Body", + description: "man: beard", + unicode_version: "13.1", + }, + { + emoji: "🧔‍♀️", + aliases: ["woman_beard"], + tags: [], + category: "People & Body", + description: "woman: beard", + unicode_version: "13.1", + }, + { + emoji: "👨‍🦰", + aliases: ["red_haired_man"], + tags: [], + category: "People & Body", + description: "man: red hair", + unicode_version: "11.0", + }, + { + emoji: "👨‍🦱", + aliases: ["curly_haired_man"], + tags: [], + category: "People & Body", + description: "man: curly hair", + unicode_version: "11.0", + }, + { + emoji: "👨‍🦳", + aliases: ["white_haired_man"], + tags: [], + category: "People & Body", + description: "man: white hair", + unicode_version: "11.0", + }, + { + emoji: "👨‍🦲", + aliases: ["bald_man"], + tags: [], + category: "People & Body", + description: "man: bald", + unicode_version: "11.0", + }, + { + emoji: "👩", + aliases: ["woman"], + tags: ["girls"], + category: "People & Body", + description: "woman", + unicode_version: "6.0", + }, + { + emoji: "👩‍🦰", + aliases: ["red_haired_woman"], + tags: [], + category: "People & Body", + description: "woman: red hair", + unicode_version: "11.0", + }, + { + emoji: "🧑‍🦰", + aliases: ["person_red_hair"], + tags: [], + category: "People & Body", + description: "person: red hair", + unicode_version: "12.1", + }, + { + emoji: "👩‍🦱", + aliases: ["curly_haired_woman"], + tags: [], + category: "People & Body", + description: "woman: curly hair", + unicode_version: "11.0", + }, + { + emoji: "🧑‍🦱", + aliases: ["person_curly_hair"], + tags: [], + category: "People & Body", + description: "person: curly hair", + unicode_version: "12.1", + }, + { + emoji: "👩‍🦳", + aliases: ["white_haired_woman"], + tags: [], + category: "People & Body", + description: "woman: white hair", + unicode_version: "11.0", + }, + { + emoji: "🧑‍🦳", + aliases: ["person_white_hair"], + tags: [], + category: "People & Body", + description: "person: white hair", + unicode_version: "12.1", + }, + { + emoji: "👩‍🦲", + aliases: ["bald_woman"], + tags: [], + category: "People & Body", + description: "woman: bald", + unicode_version: "11.0", + }, + { + emoji: "🧑‍🦲", + aliases: ["person_bald"], + tags: [], + category: "People & Body", + description: "person: bald", + unicode_version: "12.1", + }, + { + emoji: "👱‍♀️", + aliases: ["blond_haired_woman", "blonde_woman"], + tags: [], + category: "People & Body", + description: "woman: blond hair", + unicode_version: "6.0", + }, + { + emoji: "👱‍♂️", + aliases: ["blond_haired_man"], + tags: [], + category: "People & Body", + description: "man: blond hair", + unicode_version: "11.0", + }, + { + emoji: "🧓", + aliases: ["older_adult"], + tags: [], + category: "People & Body", + description: "older person", + unicode_version: "11.0", + }, + { + emoji: "👴", + aliases: ["older_man"], + tags: [], + category: "People & Body", + description: "old man", + unicode_version: "6.0", + }, + { + emoji: "👵", + aliases: ["older_woman"], + tags: [], + category: "People & Body", + description: "old woman", + unicode_version: "6.0", + }, + { + emoji: "🙍", + aliases: ["frowning_person"], + tags: [], + category: "People & Body", + description: "person frowning", + unicode_version: "6.0", + }, + { + emoji: "🙍‍♂️", + aliases: ["frowning_man"], + tags: [], + category: "People & Body", + description: "man frowning", + unicode_version: "6.0", + }, + { + emoji: "🙍‍♀️", + aliases: ["frowning_woman"], + tags: [], + category: "People & Body", + description: "woman frowning", + unicode_version: "11.0", + }, + { + emoji: "🙎", + aliases: ["pouting_face"], + tags: [], + category: "People & Body", + description: "person pouting", + unicode_version: "6.0", + }, + { + emoji: "🙎‍♂️", + aliases: ["pouting_man"], + tags: [], + category: "People & Body", + description: "man pouting", + unicode_version: "6.0", + }, + { + emoji: "🙎‍♀️", + aliases: ["pouting_woman"], + tags: [], + category: "People & Body", + description: "woman pouting", + unicode_version: "11.0", + }, + { + emoji: "🙅", + aliases: ["no_good"], + tags: ["stop", "halt", "denied"], + category: "People & Body", + description: "person gesturing NO", + unicode_version: "6.0", + }, + { + emoji: "🙅‍♂️", + aliases: ["no_good_man", "ng_man"], + tags: ["stop", "halt", "denied"], + category: "People & Body", + description: "man gesturing NO", + unicode_version: "6.0", + }, + { + emoji: "🙅‍♀️", + aliases: ["no_good_woman", "ng_woman"], + tags: ["stop", "halt", "denied"], + category: "People & Body", + description: "woman gesturing NO", + unicode_version: "11.0", + }, + { + emoji: "🙆", + aliases: ["ok_person"], + tags: [], + category: "People & Body", + description: "person gesturing OK", + unicode_version: "6.0", + }, + { + emoji: "🙆‍♂️", + aliases: ["ok_man"], + tags: [], + category: "People & Body", + description: "man gesturing OK", + unicode_version: "6.0", + }, + { + emoji: "🙆‍♀️", + aliases: ["ok_woman"], + tags: [], + category: "People & Body", + description: "woman gesturing OK", + unicode_version: "11.0", + }, + { + emoji: "💁", + aliases: ["tipping_hand_person", "information_desk_person"], + tags: [], + category: "People & Body", + description: "person tipping hand", + unicode_version: "6.0", + }, + { + emoji: "💁‍♂️", + aliases: ["tipping_hand_man", "sassy_man"], + tags: ["information"], + category: "People & Body", + description: "man tipping hand", + unicode_version: "6.0", + }, + { + emoji: "💁‍♀️", + aliases: ["tipping_hand_woman", "sassy_woman"], + tags: ["information"], + category: "People & Body", + description: "woman tipping hand", + unicode_version: "11.0", + }, + { + emoji: "🙋", + aliases: ["raising_hand"], + tags: [], + category: "People & Body", + description: "person raising hand", + unicode_version: "6.0", + }, + { + emoji: "🙋‍♂️", + aliases: ["raising_hand_man"], + tags: [], + category: "People & Body", + description: "man raising hand", + unicode_version: "6.0", + }, + { + emoji: "🙋‍♀️", + aliases: ["raising_hand_woman"], + tags: [], + category: "People & Body", + description: "woman raising hand", + unicode_version: "11.0", + }, + { + emoji: "🧏", + aliases: ["deaf_person"], + tags: [], + category: "People & Body", + description: "deaf person", + unicode_version: "12.0", + }, + { + emoji: "🧏‍♂️", + aliases: ["deaf_man"], + tags: [], + category: "People & Body", + description: "deaf man", + unicode_version: "12.0", + }, + { + emoji: "🧏‍♀️", + aliases: ["deaf_woman"], + tags: [], + category: "People & Body", + description: "deaf woman", + unicode_version: "12.0", + }, + { + emoji: "🙇", + aliases: ["bow"], + tags: ["respect", "thanks"], + category: "People & Body", + description: "person bowing", + unicode_version: "6.0", + }, + { + emoji: "🙇‍♂️", + aliases: ["bowing_man"], + tags: ["respect", "thanks"], + category: "People & Body", + description: "man bowing", + unicode_version: "11.0", + }, + { + emoji: "🙇‍♀️", + aliases: ["bowing_woman"], + tags: ["respect", "thanks"], + category: "People & Body", + description: "woman bowing", + unicode_version: "6.0", + }, + { + emoji: "🤦", + aliases: ["facepalm"], + tags: [], + category: "People & Body", + description: "person facepalming", + unicode_version: "11.0", + }, + { + emoji: "🤦‍♂️", + aliases: ["man_facepalming"], + tags: [], + category: "People & Body", + description: "man facepalming", + unicode_version: "9.0", + }, + { + emoji: "🤦‍♀️", + aliases: ["woman_facepalming"], + tags: [], + category: "People & Body", + description: "woman facepalming", + unicode_version: "9.0", + }, + { + emoji: "🤷", + aliases: ["shrug"], + tags: [], + category: "People & Body", + description: "person shrugging", + unicode_version: "11.0", + }, + { + emoji: "🤷‍♂️", + aliases: ["man_shrugging"], + tags: [], + category: "People & Body", + description: "man shrugging", + unicode_version: "9.0", + }, + { + emoji: "🤷‍♀️", + aliases: ["woman_shrugging"], + tags: [], + category: "People & Body", + description: "woman shrugging", + unicode_version: "9.0", + }, + { + emoji: "🧑‍⚕️", + aliases: ["health_worker"], + tags: [], + category: "People & Body", + description: "health worker", + unicode_version: "12.1", + }, + { + emoji: "👨‍⚕️", + aliases: ["man_health_worker"], + tags: ["doctor", "nurse"], + category: "People & Body", + description: "man health worker", + unicode_version: "", + }, + { + emoji: "👩‍⚕️", + aliases: ["woman_health_worker"], + tags: ["doctor", "nurse"], + category: "People & Body", + description: "woman health worker", + unicode_version: "", + }, + { + emoji: "🧑‍🎓", + aliases: ["student"], + tags: [], + category: "People & Body", + description: "student", + unicode_version: "12.1", + }, + { + emoji: "👨‍🎓", + aliases: ["man_student"], + tags: ["graduation"], + category: "People & Body", + description: "man student", + unicode_version: "", + }, + { + emoji: "👩‍🎓", + aliases: ["woman_student"], + tags: ["graduation"], + category: "People & Body", + description: "woman student", + unicode_version: "", + }, + { + emoji: "🧑‍🏫", + aliases: ["teacher"], + tags: [], + category: "People & Body", + description: "teacher", + unicode_version: "12.1", + }, + { + emoji: "👨‍🏫", + aliases: ["man_teacher"], + tags: ["school", "professor"], + category: "People & Body", + description: "man teacher", + unicode_version: "", + }, + { + emoji: "👩‍🏫", + aliases: ["woman_teacher"], + tags: ["school", "professor"], + category: "People & Body", + description: "woman teacher", + unicode_version: "", + }, + { + emoji: "🧑‍⚖️", + aliases: ["judge"], + tags: [], + category: "People & Body", + description: "judge", + unicode_version: "12.1", + }, + { + emoji: "👨‍⚖️", + aliases: ["man_judge"], + tags: ["justice"], + category: "People & Body", + description: "man judge", + unicode_version: "", + }, + { + emoji: "👩‍⚖️", + aliases: ["woman_judge"], + tags: ["justice"], + category: "People & Body", + description: "woman judge", + unicode_version: "", + }, + { + emoji: "🧑‍🌾", + aliases: ["farmer"], + tags: [], + category: "People & Body", + description: "farmer", + unicode_version: "12.1", + }, + { + emoji: "👨‍🌾", + aliases: ["man_farmer"], + tags: [], + category: "People & Body", + description: "man farmer", + unicode_version: "", + }, + { + emoji: "👩‍🌾", + aliases: ["woman_farmer"], + tags: [], + category: "People & Body", + description: "woman farmer", + unicode_version: "", + }, + { + emoji: "🧑‍🍳", + aliases: ["cook"], + tags: [], + category: "People & Body", + description: "cook", + unicode_version: "12.1", + }, + { + emoji: "👨‍🍳", + aliases: ["man_cook"], + tags: ["chef"], + category: "People & Body", + description: "man cook", + unicode_version: "", + }, + { + emoji: "👩‍🍳", + aliases: ["woman_cook"], + tags: ["chef"], + category: "People & Body", + description: "woman cook", + unicode_version: "", + }, + { + emoji: "🧑‍🔧", + aliases: ["mechanic"], + tags: [], + category: "People & Body", + description: "mechanic", + unicode_version: "12.1", + }, + { + emoji: "👨‍🔧", + aliases: ["man_mechanic"], + tags: [], + category: "People & Body", + description: "man mechanic", + unicode_version: "", + }, + { + emoji: "👩‍🔧", + aliases: ["woman_mechanic"], + tags: [], + category: "People & Body", + description: "woman mechanic", + unicode_version: "", + }, + { + emoji: "🧑‍🏭", + aliases: ["factory_worker"], + tags: [], + category: "People & Body", + description: "factory worker", + unicode_version: "12.1", + }, + { + emoji: "👨‍🏭", + aliases: ["man_factory_worker"], + tags: [], + category: "People & Body", + description: "man factory worker", + unicode_version: "", + }, + { + emoji: "👩‍🏭", + aliases: ["woman_factory_worker"], + tags: [], + category: "People & Body", + description: "woman factory worker", + unicode_version: "", + }, + { + emoji: "🧑‍💼", + aliases: ["office_worker"], + tags: [], + category: "People & Body", + description: "office worker", + unicode_version: "12.1", + }, + { + emoji: "👨‍💼", + aliases: ["man_office_worker"], + tags: ["business"], + category: "People & Body", + description: "man office worker", + unicode_version: "", + }, + { + emoji: "👩‍💼", + aliases: ["woman_office_worker"], + tags: ["business"], + category: "People & Body", + description: "woman office worker", + unicode_version: "", + }, + { + emoji: "🧑‍🔬", + aliases: ["scientist"], + tags: [], + category: "People & Body", + description: "scientist", + unicode_version: "12.1", + }, + { + emoji: "👨‍🔬", + aliases: ["man_scientist"], + tags: ["research"], + category: "People & Body", + description: "man scientist", + unicode_version: "", + }, + { + emoji: "👩‍🔬", + aliases: ["woman_scientist"], + tags: ["research"], + category: "People & Body", + description: "woman scientist", + unicode_version: "", + }, + { + emoji: "🧑‍💻", + aliases: ["technologist"], + tags: [], + category: "People & Body", + description: "technologist", + unicode_version: "12.1", + }, + { + emoji: "👨‍💻", + aliases: ["man_technologist"], + tags: ["coder"], + category: "People & Body", + description: "man technologist", + unicode_version: "", + }, + { + emoji: "👩‍💻", + aliases: ["woman_technologist"], + tags: ["coder"], + category: "People & Body", + description: "woman technologist", + unicode_version: "", + }, + { + emoji: "🧑‍🎤", + aliases: ["singer"], + tags: [], + category: "People & Body", + description: "singer", + unicode_version: "12.1", + }, + { + emoji: "👨‍🎤", + aliases: ["man_singer"], + tags: ["rockstar"], + category: "People & Body", + description: "man singer", + unicode_version: "", + }, + { + emoji: "👩‍🎤", + aliases: ["woman_singer"], + tags: ["rockstar"], + category: "People & Body", + description: "woman singer", + unicode_version: "", + }, + { + emoji: "🧑‍🎨", + aliases: ["artist"], + tags: [], + category: "People & Body", + description: "artist", + unicode_version: "12.1", + }, + { + emoji: "👨‍🎨", + aliases: ["man_artist"], + tags: ["painter"], + category: "People & Body", + description: "man artist", + unicode_version: "", + }, + { + emoji: "👩‍🎨", + aliases: ["woman_artist"], + tags: ["painter"], + category: "People & Body", + description: "woman artist", + unicode_version: "", + }, + { + emoji: "🧑‍✈️", + aliases: ["pilot"], + tags: [], + category: "People & Body", + description: "pilot", + unicode_version: "12.1", + }, + { + emoji: "👨‍✈️", + aliases: ["man_pilot"], + tags: [], + category: "People & Body", + description: "man pilot", + unicode_version: "", + }, + { + emoji: "👩‍✈️", + aliases: ["woman_pilot"], + tags: [], + category: "People & Body", + description: "woman pilot", + unicode_version: "", + }, + { + emoji: "🧑‍🚀", + aliases: ["astronaut"], + tags: [], + category: "People & Body", + description: "astronaut", + unicode_version: "12.1", + }, + { + emoji: "👨‍🚀", + aliases: ["man_astronaut"], + tags: ["space"], + category: "People & Body", + description: "man astronaut", + unicode_version: "", + }, + { + emoji: "👩‍🚀", + aliases: ["woman_astronaut"], + tags: ["space"], + category: "People & Body", + description: "woman astronaut", + unicode_version: "", + }, + { + emoji: "🧑‍🚒", + aliases: ["firefighter"], + tags: [], + category: "People & Body", + description: "firefighter", + unicode_version: "12.1", + }, + { + emoji: "👨‍🚒", + aliases: ["man_firefighter"], + tags: [], + category: "People & Body", + description: "man firefighter", + unicode_version: "", + }, + { + emoji: "👩‍🚒", + aliases: ["woman_firefighter"], + tags: [], + category: "People & Body", + description: "woman firefighter", + unicode_version: "", + }, + { + emoji: "👮", + aliases: ["police_officer", "cop"], + tags: ["law"], + category: "People & Body", + description: "police officer", + unicode_version: "6.0", + }, + { + emoji: "👮‍♂️", + aliases: ["policeman"], + tags: ["law", "cop"], + category: "People & Body", + description: "man police officer", + unicode_version: "11.0", + }, + { + emoji: "👮‍♀️", + aliases: ["policewoman"], + tags: ["law", "cop"], + category: "People & Body", + description: "woman police officer", + unicode_version: "6.0", + }, + { + emoji: "🕵️", + aliases: ["detective"], + tags: ["sleuth"], + category: "People & Body", + description: "detective", + unicode_version: "7.0", + }, + { + emoji: "🕵️‍♂️", + aliases: ["male_detective"], + tags: ["sleuth"], + category: "People & Body", + description: "man detective", + unicode_version: "11.0", + }, + { + emoji: "🕵️‍♀️", + aliases: ["female_detective"], + tags: ["sleuth"], + category: "People & Body", + description: "woman detective", + unicode_version: "6.0", + }, + { + emoji: "💂", + aliases: ["guard"], + tags: [], + category: "People & Body", + description: "guard", + unicode_version: "6.0", + }, + { + emoji: "💂‍♂️", + aliases: ["guardsman"], + tags: [], + category: "People & Body", + description: "man guard", + unicode_version: "11.0", + }, + { + emoji: "💂‍♀️", + aliases: ["guardswoman"], + tags: [], + category: "People & Body", + description: "woman guard", + unicode_version: "6.0", + }, + { + emoji: "🥷", + aliases: ["ninja"], + tags: [], + category: "People & Body", + description: "ninja", + unicode_version: "13.0", + }, + { + emoji: "👷", + aliases: ["construction_worker"], + tags: ["helmet"], + category: "People & Body", + description: "construction worker", + unicode_version: "6.0", + }, + { + emoji: "👷‍♂️", + aliases: ["construction_worker_man"], + tags: ["helmet"], + category: "People & Body", + description: "man construction worker", + unicode_version: "11.0", + }, + { + emoji: "👷‍♀️", + aliases: ["construction_worker_woman"], + tags: ["helmet"], + category: "People & Body", + description: "woman construction worker", + unicode_version: "6.0", + }, + { + emoji: "🤴", + aliases: ["prince"], + tags: ["crown", "royal"], + category: "People & Body", + description: "prince", + unicode_version: "9.0", + }, + { + emoji: "👸", + aliases: ["princess"], + tags: ["crown", "royal"], + category: "People & Body", + description: "princess", + unicode_version: "6.0", + }, + { + emoji: "👳", + aliases: ["person_with_turban"], + tags: [], + category: "People & Body", + description: "person wearing turban", + unicode_version: "6.0", + }, + { + emoji: "👳‍♂️", + aliases: ["man_with_turban"], + tags: [], + category: "People & Body", + description: "man wearing turban", + unicode_version: "11.0", + }, + { + emoji: "👳‍♀️", + aliases: ["woman_with_turban"], + tags: [], + category: "People & Body", + description: "woman wearing turban", + unicode_version: "6.0", + }, + { + emoji: "👲", + aliases: ["man_with_gua_pi_mao"], + tags: [], + category: "People & Body", + description: "person with skullcap", + unicode_version: "6.0", + }, + { + emoji: "🧕", + aliases: ["woman_with_headscarf"], + tags: ["hijab"], + category: "People & Body", + description: "woman with headscarf", + unicode_version: "11.0", + }, + { + emoji: "🤵", + aliases: ["person_in_tuxedo"], + tags: ["groom", "marriage", "wedding"], + category: "People & Body", + description: "person in tuxedo", + unicode_version: "9.0", + }, + { + emoji: "🤵‍♂️", + aliases: ["man_in_tuxedo"], + tags: [], + category: "People & Body", + description: "man in tuxedo", + unicode_version: "13.0", + }, + { + emoji: "🤵‍♀️", + aliases: ["woman_in_tuxedo"], + tags: [], + category: "People & Body", + description: "woman in tuxedo", + unicode_version: "13.0", + }, + { + emoji: "👰", + aliases: ["person_with_veil"], + tags: ["marriage", "wedding"], + category: "People & Body", + description: "person with veil", + unicode_version: "6.0", + }, + { + emoji: "👰‍♂️", + aliases: ["man_with_veil"], + tags: [], + category: "People & Body", + description: "man with veil", + unicode_version: "13.0", + }, + { + emoji: "👰‍♀️", + aliases: ["woman_with_veil", "bride_with_veil"], + tags: [], + category: "People & Body", + description: "woman with veil", + unicode_version: "13.0", + }, + { + emoji: "🤰", + aliases: ["pregnant_woman"], + tags: [], + category: "People & Body", + description: "pregnant woman", + unicode_version: "9.0", + }, + { + emoji: "🤱", + aliases: ["breast_feeding"], + tags: ["nursing"], + category: "People & Body", + description: "breast-feeding", + unicode_version: "11.0", + }, + { + emoji: "👩‍🍼", + aliases: ["woman_feeding_baby"], + tags: [], + category: "People & Body", + description: "woman feeding baby", + unicode_version: "13.0", + }, + { + emoji: "👨‍🍼", + aliases: ["man_feeding_baby"], + tags: [], + category: "People & Body", + description: "man feeding baby", + unicode_version: "13.0", + }, + { + emoji: "🧑‍🍼", + aliases: ["person_feeding_baby"], + tags: [], + category: "People & Body", + description: "person feeding baby", + unicode_version: "13.0", + }, + { + emoji: "👼", + aliases: ["angel"], + tags: [], + category: "People & Body", + description: "baby angel", + unicode_version: "6.0", + }, + { + emoji: "🎅", + aliases: ["santa"], + tags: ["christmas"], + category: "People & Body", + description: "Santa Claus", + unicode_version: "6.0", + }, + { + emoji: "🤶", + aliases: ["mrs_claus"], + tags: ["santa"], + category: "People & Body", + description: "Mrs. Claus", + unicode_version: "9.0", + }, + { + emoji: "🧑‍🎄", + aliases: ["mx_claus"], + tags: [], + category: "People & Body", + description: "mx claus", + unicode_version: "13.0", + }, + { + emoji: "🦸", + aliases: ["superhero"], + tags: [], + category: "People & Body", + description: "superhero", + unicode_version: "11.0", + }, + { + emoji: "🦸‍♂️", + aliases: ["superhero_man"], + tags: [], + category: "People & Body", + description: "man superhero", + unicode_version: "11.0", + }, + { + emoji: "🦸‍♀️", + aliases: ["superhero_woman"], + tags: [], + category: "People & Body", + description: "woman superhero", + unicode_version: "11.0", + }, + { + emoji: "🦹", + aliases: ["supervillain"], + tags: [], + category: "People & Body", + description: "supervillain", + unicode_version: "11.0", + }, + { + emoji: "🦹‍♂️", + aliases: ["supervillain_man"], + tags: [], + category: "People & Body", + description: "man supervillain", + unicode_version: "11.0", + }, + { + emoji: "🦹‍♀️", + aliases: ["supervillain_woman"], + tags: [], + category: "People & Body", + description: "woman supervillain", + unicode_version: "11.0", + }, + { + emoji: "🧙", + aliases: ["mage"], + tags: ["wizard"], + category: "People & Body", + description: "mage", + unicode_version: "11.0", + }, + { + emoji: "🧙‍♂️", + aliases: ["mage_man"], + tags: ["wizard"], + category: "People & Body", + description: "man mage", + unicode_version: "11.0", + }, + { + emoji: "🧙‍♀️", + aliases: ["mage_woman"], + tags: ["wizard"], + category: "People & Body", + description: "woman mage", + unicode_version: "11.0", + }, + { + emoji: "🧚", + aliases: ["fairy"], + tags: [], + category: "People & Body", + description: "fairy", + unicode_version: "11.0", + }, + { + emoji: "🧚‍♂️", + aliases: ["fairy_man"], + tags: [], + category: "People & Body", + description: "man fairy", + unicode_version: "11.0", + }, + { + emoji: "🧚‍♀️", + aliases: ["fairy_woman"], + tags: [], + category: "People & Body", + description: "woman fairy", + unicode_version: "11.0", + }, + { + emoji: "🧛", + aliases: ["vampire"], + tags: [], + category: "People & Body", + description: "vampire", + unicode_version: "11.0", + }, + { + emoji: "🧛‍♂️", + aliases: ["vampire_man"], + tags: [], + category: "People & Body", + description: "man vampire", + unicode_version: "11.0", + }, + { + emoji: "🧛‍♀️", + aliases: ["vampire_woman"], + tags: [], + category: "People & Body", + description: "woman vampire", + unicode_version: "11.0", + }, + { + emoji: "🧜", + aliases: ["merperson"], + tags: [], + category: "People & Body", + description: "merperson", + unicode_version: "11.0", + }, + { + emoji: "🧜‍♂️", + aliases: ["merman"], + tags: [], + category: "People & Body", + description: "merman", + unicode_version: "11.0", + }, + { + emoji: "🧜‍♀️", + aliases: ["mermaid"], + tags: [], + category: "People & Body", + description: "mermaid", + unicode_version: "11.0", + }, + { + emoji: "🧝", + aliases: ["elf"], + tags: [], + category: "People & Body", + description: "elf", + unicode_version: "11.0", + }, + { + emoji: "🧝‍♂️", + aliases: ["elf_man"], + tags: [], + category: "People & Body", + description: "man elf", + unicode_version: "11.0", + }, + { + emoji: "🧝‍♀️", + aliases: ["elf_woman"], + tags: [], + category: "People & Body", + description: "woman elf", + unicode_version: "11.0", + }, + { + emoji: "🧞", + aliases: ["genie"], + tags: [], + category: "People & Body", + description: "genie", + unicode_version: "11.0", + }, + { + emoji: "🧞‍♂️", + aliases: ["genie_man"], + tags: [], + category: "People & Body", + description: "man genie", + unicode_version: "11.0", + }, + { + emoji: "🧞‍♀️", + aliases: ["genie_woman"], + tags: [], + category: "People & Body", + description: "woman genie", + unicode_version: "11.0", + }, + { + emoji: "🧟", + aliases: ["zombie"], + tags: [], + category: "People & Body", + description: "zombie", + unicode_version: "11.0", + }, + { + emoji: "🧟‍♂️", + aliases: ["zombie_man"], + tags: [], + category: "People & Body", + description: "man zombie", + unicode_version: "11.0", + }, + { + emoji: "🧟‍♀️", + aliases: ["zombie_woman"], + tags: [], + category: "People & Body", + description: "woman zombie", + unicode_version: "11.0", + }, + { + emoji: "💆", + aliases: ["massage"], + tags: ["spa"], + category: "People & Body", + description: "person getting massage", + unicode_version: "6.0", + }, + { + emoji: "💆‍♂️", + aliases: ["massage_man"], + tags: ["spa"], + category: "People & Body", + description: "man getting massage", + unicode_version: "6.0", + }, + { + emoji: "💆‍♀️", + aliases: ["massage_woman"], + tags: ["spa"], + category: "People & Body", + description: "woman getting massage", + unicode_version: "11.0", + }, + { + emoji: "💇", + aliases: ["haircut"], + tags: ["beauty"], + category: "People & Body", + description: "person getting haircut", + unicode_version: "6.0", + }, + { + emoji: "💇‍♂️", + aliases: ["haircut_man"], + tags: [], + category: "People & Body", + description: "man getting haircut", + unicode_version: "6.0", + }, + { + emoji: "💇‍♀️", + aliases: ["haircut_woman"], + tags: [], + category: "People & Body", + description: "woman getting haircut", + unicode_version: "11.0", + }, + { + emoji: "🚶", + aliases: ["walking"], + tags: [], + category: "People & Body", + description: "person walking", + unicode_version: "6.0", + }, + { + emoji: "🚶‍♂️", + aliases: ["walking_man"], + tags: [], + category: "People & Body", + description: "man walking", + unicode_version: "11.0", + }, + { + emoji: "🚶‍♀️", + aliases: ["walking_woman"], + tags: [], + category: "People & Body", + description: "woman walking", + unicode_version: "6.0", + }, + { + emoji: "🧍", + aliases: ["standing_person"], + tags: [], + category: "People & Body", + description: "person standing", + unicode_version: "12.0", + }, + { + emoji: "🧍‍♂️", + aliases: ["standing_man"], + tags: [], + category: "People & Body", + description: "man standing", + unicode_version: "12.0", + }, + { + emoji: "🧍‍♀️", + aliases: ["standing_woman"], + tags: [], + category: "People & Body", + description: "woman standing", + unicode_version: "12.0", + }, + { + emoji: "🧎", + aliases: ["kneeling_person"], + tags: [], + category: "People & Body", + description: "person kneeling", + unicode_version: "12.0", + }, + { + emoji: "🧎‍♂️", + aliases: ["kneeling_man"], + tags: [], + category: "People & Body", + description: "man kneeling", + unicode_version: "12.0", + }, + { + emoji: "🧎‍♀️", + aliases: ["kneeling_woman"], + tags: [], + category: "People & Body", + description: "woman kneeling", + unicode_version: "12.0", + }, + { + emoji: "🧑‍🦯", + aliases: ["person_with_probing_cane"], + tags: [], + category: "People & Body", + description: "person with white cane", + unicode_version: "12.1", + }, + { + emoji: "👨‍🦯", + aliases: ["man_with_probing_cane"], + tags: [], + category: "People & Body", + description: "man with white cane", + unicode_version: "12.0", + }, + { + emoji: "👩‍🦯", + aliases: ["woman_with_probing_cane"], + tags: [], + category: "People & Body", + description: "woman with white cane", + unicode_version: "12.0", + }, + { + emoji: "🧑‍🦼", + aliases: ["person_in_motorized_wheelchair"], + tags: [], + category: "People & Body", + description: "person in motorized wheelchair", + unicode_version: "12.1", + }, + { + emoji: "👨‍🦼", + aliases: ["man_in_motorized_wheelchair"], + tags: [], + category: "People & Body", + description: "man in motorized wheelchair", + unicode_version: "12.0", + }, + { + emoji: "👩‍🦼", + aliases: ["woman_in_motorized_wheelchair"], + tags: [], + category: "People & Body", + description: "woman in motorized wheelchair", + unicode_version: "12.0", + }, + { + emoji: "🧑‍🦽", + aliases: ["person_in_manual_wheelchair"], + tags: [], + category: "People & Body", + description: "person in manual wheelchair", + unicode_version: "12.1", + }, + { + emoji: "👨‍🦽", + aliases: ["man_in_manual_wheelchair"], + tags: [], + category: "People & Body", + description: "man in manual wheelchair", + unicode_version: "12.0", + }, + { + emoji: "👩‍🦽", + aliases: ["woman_in_manual_wheelchair"], + tags: [], + category: "People & Body", + description: "woman in manual wheelchair", + unicode_version: "12.0", + }, + { + emoji: "🏃", + aliases: ["runner", "running"], + tags: ["exercise", "workout", "marathon"], + category: "People & Body", + description: "person running", + unicode_version: "6.0", + }, + { + emoji: "🏃‍♂️", + aliases: ["running_man"], + tags: ["exercise", "workout", "marathon"], + category: "People & Body", + description: "man running", + unicode_version: "11.0", + }, + { + emoji: "🏃‍♀️", + aliases: ["running_woman"], + tags: ["exercise", "workout", "marathon"], + category: "People & Body", + description: "woman running", + unicode_version: "6.0", + }, + { + emoji: "💃", + aliases: ["woman_dancing", "dancer"], + tags: ["dress"], + category: "People & Body", + description: "woman dancing", + unicode_version: "6.0", + }, + { + emoji: "🕺", + aliases: ["man_dancing"], + tags: ["dancer"], + category: "People & Body", + description: "man dancing", + unicode_version: "9.0", + }, + { + emoji: "🕴️", + aliases: ["business_suit_levitating"], + tags: [], + category: "People & Body", + description: "person in suit levitating", + unicode_version: "7.0", + }, + { + emoji: "👯", + aliases: ["dancers"], + tags: ["bunny"], + category: "People & Body", + description: "people with bunny ears", + unicode_version: "6.0", + }, + { + emoji: "👯‍♂️", + aliases: ["dancing_men"], + tags: ["bunny"], + category: "People & Body", + description: "men with bunny ears", + unicode_version: "6.0", + }, + { + emoji: "👯‍♀️", + aliases: ["dancing_women"], + tags: ["bunny"], + category: "People & Body", + description: "women with bunny ears", + unicode_version: "11.0", + }, + { + emoji: "🧖", + aliases: ["sauna_person"], + tags: ["steamy"], + category: "People & Body", + description: "person in steamy room", + unicode_version: "11.0", + }, + { + emoji: "🧖‍♂️", + aliases: ["sauna_man"], + tags: ["steamy"], + category: "People & Body", + description: "man in steamy room", + unicode_version: "11.0", + }, + { + emoji: "🧖‍♀️", + aliases: ["sauna_woman"], + tags: ["steamy"], + category: "People & Body", + description: "woman in steamy room", + unicode_version: "11.0", + }, + { + emoji: "🧗", + aliases: ["climbing"], + tags: ["bouldering"], + category: "People & Body", + description: "person climbing", + unicode_version: "11.0", + }, + { + emoji: "🧗‍♂️", + aliases: ["climbing_man"], + tags: ["bouldering"], + category: "People & Body", + description: "man climbing", + unicode_version: "11.0", + }, + { + emoji: "🧗‍♀️", + aliases: ["climbing_woman"], + tags: ["bouldering"], + category: "People & Body", + description: "woman climbing", + unicode_version: "11.0", + }, + { + emoji: "🤺", + aliases: ["person_fencing"], + tags: [], + category: "People & Body", + description: "person fencing", + unicode_version: "9.0", + }, + { + emoji: "🏇", + aliases: ["horse_racing"], + tags: [], + category: "People & Body", + description: "horse racing", + unicode_version: "6.0", + }, + { + emoji: "⛷️", + aliases: ["skier"], + tags: [], + category: "People & Body", + description: "skier", + unicode_version: "5.2", + }, + { + emoji: "🏂", + aliases: ["snowboarder"], + tags: [], + category: "People & Body", + description: "snowboarder", + unicode_version: "6.0", + }, + { + emoji: "🏌️", + aliases: ["golfing"], + tags: [], + category: "People & Body", + description: "person golfing", + unicode_version: "7.0", + }, + { + emoji: "🏌️‍♂️", + aliases: ["golfing_man"], + tags: [], + category: "People & Body", + description: "man golfing", + unicode_version: "11.0", + }, + { + emoji: "🏌️‍♀️", + aliases: ["golfing_woman"], + tags: [], + category: "People & Body", + description: "woman golfing", + unicode_version: "", + }, + { + emoji: "🏄", + aliases: ["surfer"], + tags: [], + category: "People & Body", + description: "person surfing", + unicode_version: "6.0", + }, + { + emoji: "🏄‍♂️", + aliases: ["surfing_man"], + tags: [], + category: "People & Body", + description: "man surfing", + unicode_version: "11.0", + }, + { + emoji: "🏄‍♀️", + aliases: ["surfing_woman"], + tags: [], + category: "People & Body", + description: "woman surfing", + unicode_version: "7.0", + }, + { + emoji: "🚣", + aliases: ["rowboat"], + tags: [], + category: "People & Body", + description: "person rowing boat", + unicode_version: "6.0", + }, + { + emoji: "🚣‍♂️", + aliases: ["rowing_man"], + tags: [], + category: "People & Body", + description: "man rowing boat", + unicode_version: "11.0", + }, + { + emoji: "🚣‍♀️", + aliases: ["rowing_woman"], + tags: [], + category: "People & Body", + description: "woman rowing boat", + unicode_version: "6.0", + }, + { + emoji: "🏊", + aliases: ["swimmer"], + tags: [], + category: "People & Body", + description: "person swimming", + unicode_version: "6.0", + }, + { + emoji: "🏊‍♂️", + aliases: ["swimming_man"], + tags: [], + category: "People & Body", + description: "man swimming", + unicode_version: "11.0", + }, + { + emoji: "🏊‍♀️", + aliases: ["swimming_woman"], + tags: [], + category: "People & Body", + description: "woman swimming", + unicode_version: "6.0", + }, + { + emoji: "⛹️", + aliases: ["bouncing_ball_person"], + tags: ["basketball"], + category: "People & Body", + description: "person bouncing ball", + unicode_version: "5.2", + }, + { + emoji: "⛹️‍♂️", + aliases: ["bouncing_ball_man", "basketball_man"], + tags: [], + category: "People & Body", + description: "man bouncing ball", + unicode_version: "11.0", + }, + { + emoji: "⛹️‍♀️", + aliases: ["bouncing_ball_woman", "basketball_woman"], + tags: [], + category: "People & Body", + description: "woman bouncing ball", + unicode_version: "7.0", + }, + { + emoji: "🏋️", + aliases: ["weight_lifting"], + tags: ["gym", "workout"], + category: "People & Body", + description: "person lifting weights", + unicode_version: "7.0", + }, + { + emoji: "🏋️‍♂️", + aliases: ["weight_lifting_man"], + tags: ["gym", "workout"], + category: "People & Body", + description: "man lifting weights", + unicode_version: "11.0", + }, + { + emoji: "🏋️‍♀️", + aliases: ["weight_lifting_woman"], + tags: ["gym", "workout"], + category: "People & Body", + description: "woman lifting weights", + unicode_version: "6.0", + }, + { + emoji: "🚴", + aliases: ["bicyclist"], + tags: [], + category: "People & Body", + description: "person biking", + unicode_version: "6.0", + }, + { + emoji: "🚴‍♂️", + aliases: ["biking_man"], + tags: [], + category: "People & Body", + description: "man biking", + unicode_version: "11.0", + }, + { + emoji: "🚴‍♀️", + aliases: ["biking_woman"], + tags: [], + category: "People & Body", + description: "woman biking", + unicode_version: "6.0", + }, + { + emoji: "🚵", + aliases: ["mountain_bicyclist"], + tags: [], + category: "People & Body", + description: "person mountain biking", + unicode_version: "6.0", + }, + { + emoji: "🚵‍♂️", + aliases: ["mountain_biking_man"], + tags: [], + category: "People & Body", + description: "man mountain biking", + unicode_version: "11.0", + }, + { + emoji: "🚵‍♀️", + aliases: ["mountain_biking_woman"], + tags: [], + category: "People & Body", + description: "woman mountain biking", + unicode_version: "6.0", + }, + { + emoji: "🤸", + aliases: ["cartwheeling"], + tags: [], + category: "People & Body", + description: "person cartwheeling", + unicode_version: "11.0", + }, + { + emoji: "🤸‍♂️", + aliases: ["man_cartwheeling"], + tags: [], + category: "People & Body", + description: "man cartwheeling", + unicode_version: "", + }, + { + emoji: "🤸‍♀️", + aliases: ["woman_cartwheeling"], + tags: [], + category: "People & Body", + description: "woman cartwheeling", + unicode_version: "", + }, + { + emoji: "🤼", + aliases: ["wrestling"], + tags: [], + category: "People & Body", + description: "people wrestling", + unicode_version: "11.0", + }, + { + emoji: "🤼‍♂️", + aliases: ["men_wrestling"], + tags: [], + category: "People & Body", + description: "men wrestling", + unicode_version: "9.0", + }, + { + emoji: "🤼‍♀️", + aliases: ["women_wrestling"], + tags: [], + category: "People & Body", + description: "women wrestling", + unicode_version: "9.0", + }, + { + emoji: "🤽", + aliases: ["water_polo"], + tags: [], + category: "People & Body", + description: "person playing water polo", + unicode_version: "11.0", + }, + { + emoji: "🤽‍♂️", + aliases: ["man_playing_water_polo"], + tags: [], + category: "People & Body", + description: "man playing water polo", + unicode_version: "9.0", + }, + { + emoji: "🤽‍♀️", + aliases: ["woman_playing_water_polo"], + tags: [], + category: "People & Body", + description: "woman playing water polo", + unicode_version: "9.0", + }, + { + emoji: "🤾", + aliases: ["handball_person"], + tags: [], + category: "People & Body", + description: "person playing handball", + unicode_version: "11.0", + }, + { + emoji: "🤾‍♂️", + aliases: ["man_playing_handball"], + tags: [], + category: "People & Body", + description: "man playing handball", + unicode_version: "9.0", + }, + { + emoji: "🤾‍♀️", + aliases: ["woman_playing_handball"], + tags: [], + category: "People & Body", + description: "woman playing handball", + unicode_version: "9.0", + }, + { + emoji: "🤹", + aliases: ["juggling_person"], + tags: [], + category: "People & Body", + description: "person juggling", + unicode_version: "11.0", + }, + { + emoji: "🤹‍♂️", + aliases: ["man_juggling"], + tags: [], + category: "People & Body", + description: "man juggling", + unicode_version: "9.0", + }, + { + emoji: "🤹‍♀️", + aliases: ["woman_juggling"], + tags: [], + category: "People & Body", + description: "woman juggling", + unicode_version: "9.0", + }, + { + emoji: "🧘", + aliases: ["lotus_position"], + tags: ["meditation"], + category: "People & Body", + description: "person in lotus position", + unicode_version: "11.0", + }, + { + emoji: "🧘‍♂️", + aliases: ["lotus_position_man"], + tags: ["meditation"], + category: "People & Body", + description: "man in lotus position", + unicode_version: "11.0", + }, + { + emoji: "🧘‍♀️", + aliases: ["lotus_position_woman"], + tags: ["meditation"], + category: "People & Body", + description: "woman in lotus position", + unicode_version: "11.0", + }, + { + emoji: "🛀", + aliases: ["bath"], + tags: ["shower"], + category: "People & Body", + description: "person taking bath", + unicode_version: "6.0", + }, + { + emoji: "🛌", + aliases: ["sleeping_bed"], + tags: [], + category: "People & Body", + description: "person in bed", + unicode_version: "7.0", + }, + { + emoji: "🧑‍🤝‍🧑", + aliases: ["people_holding_hands"], + tags: ["couple", "date"], + category: "People & Body", + description: "people holding hands", + unicode_version: "12.0", + }, + { + emoji: "👭", + aliases: ["two_women_holding_hands"], + tags: ["couple", "date"], + category: "People & Body", + description: "women holding hands", + unicode_version: "6.0", + }, + { + emoji: "👫", + aliases: ["couple"], + tags: ["date"], + category: "People & Body", + description: "woman and man holding hands", + unicode_version: "6.0", + }, + { + emoji: "👬", + aliases: ["two_men_holding_hands"], + tags: ["couple", "date"], + category: "People & Body", + description: "men holding hands", + unicode_version: "6.0", + }, + { + emoji: "💏", + aliases: ["couplekiss"], + tags: [], + category: "People & Body", + description: "kiss", + unicode_version: "6.0", + }, + { + emoji: "👩‍❤️‍💋‍👨", + aliases: ["couplekiss_man_woman"], + tags: [], + category: "People & Body", + description: "kiss: woman, man", + unicode_version: "11.0", + }, + { + emoji: "👨‍❤️‍💋‍👨", + aliases: ["couplekiss_man_man"], + tags: [], + category: "People & Body", + description: "kiss: man, man", + unicode_version: "6.0", + }, + { + emoji: "👩‍❤️‍💋‍👩", + aliases: ["couplekiss_woman_woman"], + tags: [], + category: "People & Body", + description: "kiss: woman, woman", + unicode_version: "6.0", + }, + { + emoji: "💑", + aliases: ["couple_with_heart"], + tags: [], + category: "People & Body", + description: "couple with heart", + unicode_version: "6.0", + }, + { + emoji: "👩‍❤️‍👨", + aliases: ["couple_with_heart_woman_man"], + tags: [], + category: "People & Body", + description: "couple with heart: woman, man", + unicode_version: "11.0", + }, + { + emoji: "👨‍❤️‍👨", + aliases: ["couple_with_heart_man_man"], + tags: [], + category: "People & Body", + description: "couple with heart: man, man", + unicode_version: "6.0", + }, + { + emoji: "👩‍❤️‍👩", + aliases: ["couple_with_heart_woman_woman"], + tags: [], + category: "People & Body", + description: "couple with heart: woman, woman", + unicode_version: "6.0", + }, + { + emoji: "👪", + aliases: ["family"], + tags: ["home", "parents", "child"], + category: "People & Body", + description: "family", + unicode_version: "6.0", + }, + { + emoji: "👨‍👩‍👦", + aliases: ["family_man_woman_boy"], + tags: [], + category: "People & Body", + description: "family: man, woman, boy", + unicode_version: "11.0", + }, + { + emoji: "👨‍👩‍👧", + aliases: ["family_man_woman_girl"], + tags: [], + category: "People & Body", + description: "family: man, woman, girl", + unicode_version: "6.0", + }, + { + emoji: "👨‍👩‍👧‍👦", + aliases: ["family_man_woman_girl_boy"], + tags: [], + category: "People & Body", + description: "family: man, woman, girl, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👩‍👦‍👦", + aliases: ["family_man_woman_boy_boy"], + tags: [], + category: "People & Body", + description: "family: man, woman, boy, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👩‍👧‍👧", + aliases: ["family_man_woman_girl_girl"], + tags: [], + category: "People & Body", + description: "family: man, woman, girl, girl", + unicode_version: "6.0", + }, + { + emoji: "👨‍👨‍👦", + aliases: ["family_man_man_boy"], + tags: [], + category: "People & Body", + description: "family: man, man, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👨‍👧", + aliases: ["family_man_man_girl"], + tags: [], + category: "People & Body", + description: "family: man, man, girl", + unicode_version: "6.0", + }, + { + emoji: "👨‍👨‍👧‍👦", + aliases: ["family_man_man_girl_boy"], + tags: [], + category: "People & Body", + description: "family: man, man, girl, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👨‍👦‍👦", + aliases: ["family_man_man_boy_boy"], + tags: [], + category: "People & Body", + description: "family: man, man, boy, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👨‍👧‍👧", + aliases: ["family_man_man_girl_girl"], + tags: [], + category: "People & Body", + description: "family: man, man, girl, girl", + unicode_version: "6.0", + }, + { + emoji: "👩‍👩‍👦", + aliases: ["family_woman_woman_boy"], + tags: [], + category: "People & Body", + description: "family: woman, woman, boy", + unicode_version: "6.0", + }, + { + emoji: "👩‍👩‍👧", + aliases: ["family_woman_woman_girl"], + tags: [], + category: "People & Body", + description: "family: woman, woman, girl", + unicode_version: "6.0", + }, + { + emoji: "👩‍👩‍👧‍👦", + aliases: ["family_woman_woman_girl_boy"], + tags: [], + category: "People & Body", + description: "family: woman, woman, girl, boy", + unicode_version: "6.0", + }, + { + emoji: "👩‍👩‍👦‍👦", + aliases: ["family_woman_woman_boy_boy"], + tags: [], + category: "People & Body", + description: "family: woman, woman, boy, boy", + unicode_version: "6.0", + }, + { + emoji: "👩‍👩‍👧‍👧", + aliases: ["family_woman_woman_girl_girl"], + tags: [], + category: "People & Body", + description: "family: woman, woman, girl, girl", + unicode_version: "6.0", + }, + { + emoji: "👨‍👦", + aliases: ["family_man_boy"], + tags: [], + category: "People & Body", + description: "family: man, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👦‍👦", + aliases: ["family_man_boy_boy"], + tags: [], + category: "People & Body", + description: "family: man, boy, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👧", + aliases: ["family_man_girl"], + tags: [], + category: "People & Body", + description: "family: man, girl", + unicode_version: "6.0", + }, + { + emoji: "👨‍👧‍👦", + aliases: ["family_man_girl_boy"], + tags: [], + category: "People & Body", + description: "family: man, girl, boy", + unicode_version: "6.0", + }, + { + emoji: "👨‍👧‍👧", + aliases: ["family_man_girl_girl"], + tags: [], + category: "People & Body", + description: "family: man, girl, girl", + unicode_version: "6.0", + }, + { + emoji: "👩‍👦", + aliases: ["family_woman_boy"], + tags: [], + category: "People & Body", + description: "family: woman, boy", + unicode_version: "6.0", + }, + { + emoji: "👩‍👦‍👦", + aliases: ["family_woman_boy_boy"], + tags: [], + category: "People & Body", + description: "family: woman, boy, boy", + unicode_version: "6.0", + }, + { + emoji: "👩‍👧", + aliases: ["family_woman_girl"], + tags: [], + category: "People & Body", + description: "family: woman, girl", + unicode_version: "6.0", + }, + { + emoji: "👩‍👧‍👦", + aliases: ["family_woman_girl_boy"], + tags: [], + category: "People & Body", + description: "family: woman, girl, boy", + unicode_version: "6.0", + }, + { + emoji: "👩‍👧‍👧", + aliases: ["family_woman_girl_girl"], + tags: [], + category: "People & Body", + description: "family: woman, girl, girl", + unicode_version: "6.0", + }, + { + emoji: "🗣️", + aliases: ["speaking_head"], + tags: [], + category: "People & Body", + description: "speaking head", + unicode_version: "7.0", + }, + { + emoji: "👤", + aliases: ["bust_in_silhouette"], + tags: ["user"], + category: "People & Body", + description: "bust in silhouette", + unicode_version: "6.0", + }, + { + emoji: "👥", + aliases: ["busts_in_silhouette"], + tags: ["users", "group", "team"], + category: "People & Body", + description: "busts in silhouette", + unicode_version: "6.0", + }, + { + emoji: "🫂", + aliases: ["people_hugging"], + tags: [], + category: "People & Body", + description: "people hugging", + unicode_version: "13.0", + }, + { + emoji: "👣", + aliases: ["footprints"], + tags: ["feet", "tracks"], + category: "People & Body", + description: "footprints", + unicode_version: "6.0", + }, + { + emoji: "🐵", + aliases: ["monkey_face"], + tags: [], + category: "Animals & Nature", + description: "monkey face", + unicode_version: "6.0", + }, + { + emoji: "🐒", + aliases: ["monkey"], + tags: [], + category: "Animals & Nature", + description: "monkey", + unicode_version: "6.0", + }, + { + emoji: "🦍", + aliases: ["gorilla"], + tags: [], + category: "Animals & Nature", + description: "gorilla", + unicode_version: "9.0", + }, + { + emoji: "🦧", + aliases: ["orangutan"], + tags: [], + category: "Animals & Nature", + description: "orangutan", + unicode_version: "12.0", + }, + { + emoji: "🐶", + aliases: ["dog"], + tags: ["pet"], + category: "Animals & Nature", + description: "dog face", + unicode_version: "6.0", + }, + { + emoji: "🐕", + aliases: ["dog2"], + tags: [], + category: "Animals & Nature", + description: "dog", + unicode_version: "6.0", + }, + { + emoji: "🦮", + aliases: ["guide_dog"], + tags: [], + category: "Animals & Nature", + description: "guide dog", + unicode_version: "12.0", + }, + { + emoji: "🐕‍🦺", + aliases: ["service_dog"], + tags: [], + category: "Animals & Nature", + description: "service dog", + unicode_version: "12.0", + }, + { + emoji: "🐩", + aliases: ["poodle"], + tags: ["dog"], + category: "Animals & Nature", + description: "poodle", + unicode_version: "6.0", + }, + { + emoji: "🐺", + aliases: ["wolf"], + tags: [], + category: "Animals & Nature", + description: "wolf", + unicode_version: "6.0", + }, + { + emoji: "🦊", + aliases: ["fox_face"], + tags: [], + category: "Animals & Nature", + description: "fox", + unicode_version: "9.0", + }, + { + emoji: "🦝", + aliases: ["raccoon"], + tags: [], + category: "Animals & Nature", + description: "raccoon", + unicode_version: "11.0", + }, + { + emoji: "🐱", + aliases: ["cat"], + tags: ["pet"], + category: "Animals & Nature", + description: "cat face", + unicode_version: "6.0", + }, + { + emoji: "🐈", + aliases: ["cat2"], + tags: [], + category: "Animals & Nature", + description: "cat", + unicode_version: "6.0", + }, + { + emoji: "🐈‍⬛", + aliases: ["black_cat"], + tags: [], + category: "Animals & Nature", + description: "black cat", + unicode_version: "13.0", + }, + { + emoji: "🦁", + aliases: ["lion"], + tags: [], + category: "Animals & Nature", + description: "lion", + unicode_version: "8.0", + }, + { + emoji: "🐯", + aliases: ["tiger"], + tags: [], + category: "Animals & Nature", + description: "tiger face", + unicode_version: "6.0", + }, + { + emoji: "🐅", + aliases: ["tiger2"], + tags: [], + category: "Animals & Nature", + description: "tiger", + unicode_version: "6.0", + }, + { + emoji: "🐆", + aliases: ["leopard"], + tags: [], + category: "Animals & Nature", + description: "leopard", + unicode_version: "6.0", + }, + { + emoji: "🐴", + aliases: ["horse"], + tags: [], + category: "Animals & Nature", + description: "horse face", + unicode_version: "6.0", + }, + { + emoji: "🐎", + aliases: ["racehorse"], + tags: ["speed"], + category: "Animals & Nature", + description: "horse", + unicode_version: "6.0", + }, + { + emoji: "🦄", + aliases: ["unicorn"], + tags: [], + category: "Animals & Nature", + description: "unicorn", + unicode_version: "8.0", + }, + { + emoji: "🦓", + aliases: ["zebra"], + tags: [], + category: "Animals & Nature", + description: "zebra", + unicode_version: "11.0", + }, + { + emoji: "🦌", + aliases: ["deer"], + tags: [], + category: "Animals & Nature", + description: "deer", + unicode_version: "9.0", + }, + { + emoji: "🦬", + aliases: ["bison"], + tags: [], + category: "Animals & Nature", + description: "bison", + unicode_version: "13.0", + }, + { + emoji: "🐮", + aliases: ["cow"], + tags: [], + category: "Animals & Nature", + description: "cow face", + unicode_version: "6.0", + }, + { + emoji: "🐂", + aliases: ["ox"], + tags: [], + category: "Animals & Nature", + description: "ox", + unicode_version: "6.0", + }, + { + emoji: "🐃", + aliases: ["water_buffalo"], + tags: [], + category: "Animals & Nature", + description: "water buffalo", + unicode_version: "6.0", + }, + { + emoji: "🐄", + aliases: ["cow2"], + tags: [], + category: "Animals & Nature", + description: "cow", + unicode_version: "6.0", + }, + { + emoji: "🐷", + aliases: ["pig"], + tags: [], + category: "Animals & Nature", + description: "pig face", + unicode_version: "6.0", + }, + { + emoji: "🐖", + aliases: ["pig2"], + tags: [], + category: "Animals & Nature", + description: "pig", + unicode_version: "6.0", + }, + { + emoji: "🐗", + aliases: ["boar"], + tags: [], + category: "Animals & Nature", + description: "boar", + unicode_version: "6.0", + }, + { + emoji: "🐽", + aliases: ["pig_nose"], + tags: [], + category: "Animals & Nature", + description: "pig nose", + unicode_version: "6.0", + }, + { + emoji: "🐏", + aliases: ["ram"], + tags: [], + category: "Animals & Nature", + description: "ram", + unicode_version: "6.0", + }, + { + emoji: "🐑", + aliases: ["sheep"], + tags: [], + category: "Animals & Nature", + description: "ewe", + unicode_version: "6.0", + }, + { + emoji: "🐐", + aliases: ["goat"], + tags: [], + category: "Animals & Nature", + description: "goat", + unicode_version: "6.0", + }, + { + emoji: "🐪", + aliases: ["dromedary_camel"], + tags: ["desert"], + category: "Animals & Nature", + description: "camel", + unicode_version: "6.0", + }, + { + emoji: "🐫", + aliases: ["camel"], + tags: [], + category: "Animals & Nature", + description: "two-hump camel", + unicode_version: "6.0", + }, + { + emoji: "🦙", + aliases: ["llama"], + tags: [], + category: "Animals & Nature", + description: "llama", + unicode_version: "11.0", + }, + { + emoji: "🦒", + aliases: ["giraffe"], + tags: [], + category: "Animals & Nature", + description: "giraffe", + unicode_version: "11.0", + }, + { + emoji: "🐘", + aliases: ["elephant"], + tags: [], + category: "Animals & Nature", + description: "elephant", + unicode_version: "6.0", + }, + { + emoji: "🦣", + aliases: ["mammoth"], + tags: [], + category: "Animals & Nature", + description: "mammoth", + unicode_version: "13.0", + }, + { + emoji: "🦏", + aliases: ["rhinoceros"], + tags: [], + category: "Animals & Nature", + description: "rhinoceros", + unicode_version: "9.0", + }, + { + emoji: "🦛", + aliases: ["hippopotamus"], + tags: [], + category: "Animals & Nature", + description: "hippopotamus", + unicode_version: "11.0", + }, + { + emoji: "🐭", + aliases: ["mouse"], + tags: [], + category: "Animals & Nature", + description: "mouse face", + unicode_version: "6.0", + }, + { + emoji: "🐁", + aliases: ["mouse2"], + tags: [], + category: "Animals & Nature", + description: "mouse", + unicode_version: "6.0", + }, + { + emoji: "🐀", + aliases: ["rat"], + tags: [], + category: "Animals & Nature", + description: "rat", + unicode_version: "6.0", + }, + { + emoji: "🐹", + aliases: ["hamster"], + tags: ["pet"], + category: "Animals & Nature", + description: "hamster", + unicode_version: "6.0", + }, + { + emoji: "🐰", + aliases: ["rabbit"], + tags: ["bunny"], + category: "Animals & Nature", + description: "rabbit face", + unicode_version: "6.0", + }, + { + emoji: "🐇", + aliases: ["rabbit2"], + tags: [], + category: "Animals & Nature", + description: "rabbit", + unicode_version: "6.0", + }, + { + emoji: "🐿️", + aliases: ["chipmunk"], + tags: [], + category: "Animals & Nature", + description: "chipmunk", + unicode_version: "7.0", + }, + { + emoji: "🦫", + aliases: ["beaver"], + tags: [], + category: "Animals & Nature", + description: "beaver", + unicode_version: "13.0", + }, + { + emoji: "🦔", + aliases: ["hedgehog"], + tags: [], + category: "Animals & Nature", + description: "hedgehog", + unicode_version: "11.0", + }, + { + emoji: "🦇", + aliases: ["bat"], + tags: [], + category: "Animals & Nature", + description: "bat", + unicode_version: "9.0", + }, + { + emoji: "🐻", + aliases: ["bear"], + tags: [], + category: "Animals & Nature", + description: "bear", + unicode_version: "6.0", + }, + { + emoji: "🐻‍❄️", + aliases: ["polar_bear"], + tags: [], + category: "Animals & Nature", + description: "polar bear", + unicode_version: "13.0", + }, + { + emoji: "🐨", + aliases: ["koala"], + tags: [], + category: "Animals & Nature", + description: "koala", + unicode_version: "6.0", + }, + { + emoji: "🐼", + aliases: ["panda_face"], + tags: [], + category: "Animals & Nature", + description: "panda", + unicode_version: "6.0", + }, + { + emoji: "🦥", + aliases: ["sloth"], + tags: [], + category: "Animals & Nature", + description: "sloth", + unicode_version: "12.0", + }, + { + emoji: "🦦", + aliases: ["otter"], + tags: [], + category: "Animals & Nature", + description: "otter", + unicode_version: "12.0", + }, + { + emoji: "🦨", + aliases: ["skunk"], + tags: [], + category: "Animals & Nature", + description: "skunk", + unicode_version: "12.0", + }, + { + emoji: "🦘", + aliases: ["kangaroo"], + tags: [], + category: "Animals & Nature", + description: "kangaroo", + unicode_version: "11.0", + }, + { + emoji: "🦡", + aliases: ["badger"], + tags: [], + category: "Animals & Nature", + description: "badger", + unicode_version: "11.0", + }, + { + emoji: "🐾", + aliases: ["feet", "paw_prints"], + tags: [], + category: "Animals & Nature", + description: "paw prints", + unicode_version: "6.0", + }, + { + emoji: "🦃", + aliases: ["turkey"], + tags: ["thanksgiving"], + category: "Animals & Nature", + description: "turkey", + unicode_version: "8.0", + }, + { + emoji: "🐔", + aliases: ["chicken"], + tags: [], + category: "Animals & Nature", + description: "chicken", + unicode_version: "6.0", + }, + { + emoji: "🐓", + aliases: ["rooster"], + tags: [], + category: "Animals & Nature", + description: "rooster", + unicode_version: "6.0", + }, + { + emoji: "🐣", + aliases: ["hatching_chick"], + tags: [], + category: "Animals & Nature", + description: "hatching chick", + unicode_version: "6.0", + }, + { + emoji: "🐤", + aliases: ["baby_chick"], + tags: [], + category: "Animals & Nature", + description: "baby chick", + unicode_version: "6.0", + }, + { + emoji: "🐥", + aliases: ["hatched_chick"], + tags: [], + category: "Animals & Nature", + description: "front-facing baby chick", + unicode_version: "6.0", + }, + { + emoji: "🐦", + aliases: ["bird"], + tags: [], + category: "Animals & Nature", + description: "bird", + unicode_version: "6.0", + }, + { + emoji: "🐧", + aliases: ["penguin"], + tags: [], + category: "Animals & Nature", + description: "penguin", + unicode_version: "6.0", + }, + { + emoji: "🕊️", + aliases: ["dove"], + tags: ["peace"], + category: "Animals & Nature", + description: "dove", + unicode_version: "7.0", + }, + { + emoji: "🦅", + aliases: ["eagle"], + tags: [], + category: "Animals & Nature", + description: "eagle", + unicode_version: "9.0", + }, + { + emoji: "🦆", + aliases: ["duck"], + tags: [], + category: "Animals & Nature", + description: "duck", + unicode_version: "9.0", + }, + { + emoji: "🦢", + aliases: ["swan"], + tags: [], + category: "Animals & Nature", + description: "swan", + unicode_version: "11.0", + }, + { + emoji: "🦉", + aliases: ["owl"], + tags: [], + category: "Animals & Nature", + description: "owl", + unicode_version: "9.0", + }, + { + emoji: "🦤", + aliases: ["dodo"], + tags: [], + category: "Animals & Nature", + description: "dodo", + unicode_version: "13.0", + }, + { + emoji: "🪶", + aliases: ["feather"], + tags: [], + category: "Animals & Nature", + description: "feather", + unicode_version: "13.0", + }, + { + emoji: "🦩", + aliases: ["flamingo"], + tags: [], + category: "Animals & Nature", + description: "flamingo", + unicode_version: "12.0", + }, + { + emoji: "🦚", + aliases: ["peacock"], + tags: [], + category: "Animals & Nature", + description: "peacock", + unicode_version: "11.0", + }, + { + emoji: "🦜", + aliases: ["parrot"], + tags: [], + category: "Animals & Nature", + description: "parrot", + unicode_version: "11.0", + }, + { + emoji: "🐸", + aliases: ["frog"], + tags: [], + category: "Animals & Nature", + description: "frog", + unicode_version: "6.0", + }, + { + emoji: "🐊", + aliases: ["crocodile"], + tags: [], + category: "Animals & Nature", + description: "crocodile", + unicode_version: "6.0", + }, + { + emoji: "🐢", + aliases: ["turtle"], + tags: ["slow"], + category: "Animals & Nature", + description: "turtle", + unicode_version: "6.0", + }, + { + emoji: "🦎", + aliases: ["lizard"], + tags: [], + category: "Animals & Nature", + description: "lizard", + unicode_version: "9.0", + }, + { + emoji: "🐍", + aliases: ["snake"], + tags: [], + category: "Animals & Nature", + description: "snake", + unicode_version: "6.0", + }, + { + emoji: "🐲", + aliases: ["dragon_face"], + tags: [], + category: "Animals & Nature", + description: "dragon face", + unicode_version: "6.0", + }, + { + emoji: "🐉", + aliases: ["dragon"], + tags: [], + category: "Animals & Nature", + description: "dragon", + unicode_version: "6.0", + }, + { + emoji: "🦕", + aliases: ["sauropod"], + tags: ["dinosaur"], + category: "Animals & Nature", + description: "sauropod", + unicode_version: "11.0", + }, + { + emoji: "🦖", + aliases: ["t-rex"], + tags: ["dinosaur"], + category: "Animals & Nature", + description: "T-Rex", + unicode_version: "11.0", + }, + { + emoji: "🐳", + aliases: ["whale"], + tags: ["sea"], + category: "Animals & Nature", + description: "spouting whale", + unicode_version: "6.0", + }, + { + emoji: "🐋", + aliases: ["whale2"], + tags: [], + category: "Animals & Nature", + description: "whale", + unicode_version: "6.0", + }, + { + emoji: "🐬", + aliases: ["dolphin", "flipper"], + tags: [], + category: "Animals & Nature", + description: "dolphin", + unicode_version: "6.0", + }, + { + emoji: "🦭", + aliases: ["seal"], + tags: [], + category: "Animals & Nature", + description: "seal", + unicode_version: "13.0", + }, + { + emoji: "🐟", + aliases: ["fish"], + tags: [], + category: "Animals & Nature", + description: "fish", + unicode_version: "6.0", + }, + { + emoji: "🐠", + aliases: ["tropical_fish"], + tags: [], + category: "Animals & Nature", + description: "tropical fish", + unicode_version: "6.0", + }, + { + emoji: "🐡", + aliases: ["blowfish"], + tags: [], + category: "Animals & Nature", + description: "blowfish", + unicode_version: "6.0", + }, + { + emoji: "🦈", + aliases: ["shark"], + tags: [], + category: "Animals & Nature", + description: "shark", + unicode_version: "9.0", + }, + { + emoji: "🐙", + aliases: ["octopus"], + tags: [], + category: "Animals & Nature", + description: "octopus", + unicode_version: "6.0", + }, + { + emoji: "🐚", + aliases: ["shell"], + tags: ["sea", "beach"], + category: "Animals & Nature", + description: "spiral shell", + unicode_version: "6.0", + }, + { + emoji: "🐌", + aliases: ["snail"], + tags: ["slow"], + category: "Animals & Nature", + description: "snail", + unicode_version: "6.0", + }, + { + emoji: "🦋", + aliases: ["butterfly"], + tags: [], + category: "Animals & Nature", + description: "butterfly", + unicode_version: "9.0", + }, + { + emoji: "🐛", + aliases: ["bug"], + tags: [], + category: "Animals & Nature", + description: "bug", + unicode_version: "6.0", + }, + { + emoji: "🐜", + aliases: ["ant"], + tags: [], + category: "Animals & Nature", + description: "ant", + unicode_version: "6.0", + }, + { + emoji: "🐝", + aliases: ["bee", "honeybee"], + tags: [], + category: "Animals & Nature", + description: "honeybee", + unicode_version: "6.0", + }, + { + emoji: "🪲", + aliases: ["beetle"], + tags: [], + category: "Animals & Nature", + description: "beetle", + unicode_version: "13.0", + }, + { + emoji: "🐞", + aliases: ["lady_beetle"], + tags: ["bug"], + category: "Animals & Nature", + description: "lady beetle", + unicode_version: "6.0", + }, + { + emoji: "🦗", + aliases: ["cricket"], + tags: [], + category: "Animals & Nature", + description: "cricket", + unicode_version: "11.0", + }, + { + emoji: "🪳", + aliases: ["cockroach"], + tags: [], + category: "Animals & Nature", + description: "cockroach", + unicode_version: "13.0", + }, + { + emoji: "🕷️", + aliases: ["spider"], + tags: [], + category: "Animals & Nature", + description: "spider", + unicode_version: "7.0", + }, + { + emoji: "🕸️", + aliases: ["spider_web"], + tags: [], + category: "Animals & Nature", + description: "spider web", + unicode_version: "7.0", + }, + { + emoji: "🦂", + aliases: ["scorpion"], + tags: [], + category: "Animals & Nature", + description: "scorpion", + unicode_version: "8.0", + }, + { + emoji: "🦟", + aliases: ["mosquito"], + tags: [], + category: "Animals & Nature", + description: "mosquito", + unicode_version: "11.0", + }, + { + emoji: "🪰", + aliases: ["fly"], + tags: [], + category: "Animals & Nature", + description: "fly", + unicode_version: "13.0", + }, + { + emoji: "🪱", + aliases: ["worm"], + tags: [], + category: "Animals & Nature", + description: "worm", + unicode_version: "13.0", + }, + { + emoji: "🦠", + aliases: ["microbe"], + tags: ["germ"], + category: "Animals & Nature", + description: "microbe", + unicode_version: "11.0", + }, + { + emoji: "💐", + aliases: ["bouquet"], + tags: ["flowers"], + category: "Animals & Nature", + description: "bouquet", + unicode_version: "6.0", + }, + { + emoji: "🌸", + aliases: ["cherry_blossom"], + tags: ["flower", "spring"], + category: "Animals & Nature", + description: "cherry blossom", + unicode_version: "6.0", + }, + { + emoji: "💮", + aliases: ["white_flower"], + tags: [], + category: "Animals & Nature", + description: "white flower", + unicode_version: "6.0", + }, + { + emoji: "🏵️", + aliases: ["rosette"], + tags: [], + category: "Animals & Nature", + description: "rosette", + unicode_version: "7.0", + }, + { + emoji: "🌹", + aliases: ["rose"], + tags: ["flower"], + category: "Animals & Nature", + description: "rose", + unicode_version: "6.0", + }, + { + emoji: "🥀", + aliases: ["wilted_flower"], + tags: [], + category: "Animals & Nature", + description: "wilted flower", + unicode_version: "9.0", + }, + { + emoji: "🌺", + aliases: ["hibiscus"], + tags: [], + category: "Animals & Nature", + description: "hibiscus", + unicode_version: "6.0", + }, + { + emoji: "🌻", + aliases: ["sunflower"], + tags: [], + category: "Animals & Nature", + description: "sunflower", + unicode_version: "6.0", + }, + { + emoji: "🌼", + aliases: ["blossom"], + tags: [], + category: "Animals & Nature", + description: "blossom", + unicode_version: "6.0", + }, + { + emoji: "🌷", + aliases: ["tulip"], + tags: ["flower"], + category: "Animals & Nature", + description: "tulip", + unicode_version: "6.0", + }, + { + emoji: "🌱", + aliases: ["seedling"], + tags: ["plant"], + category: "Animals & Nature", + description: "seedling", + unicode_version: "6.0", + }, + { + emoji: "🪴", + aliases: ["potted_plant"], + tags: [], + category: "Animals & Nature", + description: "potted plant", + unicode_version: "13.0", + }, + { + emoji: "🌲", + aliases: ["evergreen_tree"], + tags: ["wood"], + category: "Animals & Nature", + description: "evergreen tree", + unicode_version: "6.0", + }, + { + emoji: "🌳", + aliases: ["deciduous_tree"], + tags: ["wood"], + category: "Animals & Nature", + description: "deciduous tree", + unicode_version: "6.0", + }, + { + emoji: "🌴", + aliases: ["palm_tree"], + tags: [], + category: "Animals & Nature", + description: "palm tree", + unicode_version: "6.0", + }, + { + emoji: "🌵", + aliases: ["cactus"], + tags: [], + category: "Animals & Nature", + description: "cactus", + unicode_version: "6.0", + }, + { + emoji: "🌾", + aliases: ["ear_of_rice"], + tags: [], + category: "Animals & Nature", + description: "sheaf of rice", + unicode_version: "6.0", + }, + { + emoji: "🌿", + aliases: ["herb"], + tags: [], + category: "Animals & Nature", + description: "herb", + unicode_version: "6.0", + }, + { + emoji: "☘️", + aliases: ["shamrock"], + tags: [], + category: "Animals & Nature", + description: "shamrock", + unicode_version: "4.1", + }, + { + emoji: "🍀", + aliases: ["four_leaf_clover"], + tags: ["luck"], + category: "Animals & Nature", + description: "four leaf clover", + unicode_version: "6.0", + }, + { + emoji: "🍁", + aliases: ["maple_leaf"], + tags: ["canada"], + category: "Animals & Nature", + description: "maple leaf", + unicode_version: "6.0", + }, + { + emoji: "🍂", + aliases: ["fallen_leaf"], + tags: ["autumn"], + category: "Animals & Nature", + description: "fallen leaf", + unicode_version: "6.0", + }, + { + emoji: "🍃", + aliases: ["leaves"], + tags: ["leaf"], + category: "Animals & Nature", + description: "leaf fluttering in wind", + unicode_version: "6.0", + }, + { + emoji: "🍇", + aliases: ["grapes"], + tags: [], + category: "Food & Drink", + description: "grapes", + unicode_version: "6.0", + }, + { + emoji: "🍈", + aliases: ["melon"], + tags: [], + category: "Food & Drink", + description: "melon", + unicode_version: "6.0", + }, + { + emoji: "🍉", + aliases: ["watermelon"], + tags: [], + category: "Food & Drink", + description: "watermelon", + unicode_version: "6.0", + }, + { + emoji: "🍊", + aliases: ["tangerine", "orange", "mandarin"], + tags: [], + category: "Food & Drink", + description: "tangerine", + unicode_version: "6.0", + }, + { + emoji: "🍋", + aliases: ["lemon"], + tags: [], + category: "Food & Drink", + description: "lemon", + unicode_version: "6.0", + }, + { + emoji: "🍌", + aliases: ["banana"], + tags: ["fruit"], + category: "Food & Drink", + description: "banana", + unicode_version: "6.0", + }, + { + emoji: "🍍", + aliases: ["pineapple"], + tags: [], + category: "Food & Drink", + description: "pineapple", + unicode_version: "6.0", + }, + { + emoji: "🥭", + aliases: ["mango"], + tags: [], + category: "Food & Drink", + description: "mango", + unicode_version: "11.0", + }, + { + emoji: "🍎", + aliases: ["apple"], + tags: [], + category: "Food & Drink", + description: "red apple", + unicode_version: "6.0", + }, + { + emoji: "🍏", + aliases: ["green_apple"], + tags: ["fruit"], + category: "Food & Drink", + description: "green apple", + unicode_version: "6.0", + }, + { + emoji: "🍐", + aliases: ["pear"], + tags: [], + category: "Food & Drink", + description: "pear", + unicode_version: "6.0", + }, + { + emoji: "🍑", + aliases: ["peach"], + tags: [], + category: "Food & Drink", + description: "peach", + unicode_version: "6.0", + }, + { + emoji: "🍒", + aliases: ["cherries"], + tags: ["fruit"], + category: "Food & Drink", + description: "cherries", + unicode_version: "6.0", + }, + { + emoji: "🍓", + aliases: ["strawberry"], + tags: ["fruit"], + category: "Food & Drink", + description: "strawberry", + unicode_version: "6.0", + }, + { + emoji: "🫐", + aliases: ["blueberries"], + tags: [], + category: "Food & Drink", + description: "blueberries", + unicode_version: "13.0", + }, + { + emoji: "🥝", + aliases: ["kiwi_fruit"], + tags: [], + category: "Food & Drink", + description: "kiwi fruit", + unicode_version: "9.0", + }, + { + emoji: "🍅", + aliases: ["tomato"], + tags: [], + category: "Food & Drink", + description: "tomato", + unicode_version: "6.0", + }, + { + emoji: "🫒", + aliases: ["olive"], + tags: [], + category: "Food & Drink", + description: "olive", + unicode_version: "13.0", + }, + { + emoji: "🥥", + aliases: ["coconut"], + tags: [], + category: "Food & Drink", + description: "coconut", + unicode_version: "11.0", + }, + { + emoji: "🥑", + aliases: ["avocado"], + tags: [], + category: "Food & Drink", + description: "avocado", + unicode_version: "9.0", + }, + { + emoji: "🍆", + aliases: ["eggplant"], + tags: ["aubergine"], + category: "Food & Drink", + description: "eggplant", + unicode_version: "6.0", + }, + { + emoji: "🥔", + aliases: ["potato"], + tags: [], + category: "Food & Drink", + description: "potato", + unicode_version: "9.0", + }, + { + emoji: "🥕", + aliases: ["carrot"], + tags: [], + category: "Food & Drink", + description: "carrot", + unicode_version: "9.0", + }, + { + emoji: "🌽", + aliases: ["corn"], + tags: [], + category: "Food & Drink", + description: "ear of corn", + unicode_version: "6.0", + }, + { + emoji: "🌶️", + aliases: ["hot_pepper"], + tags: ["spicy"], + category: "Food & Drink", + description: "hot pepper", + unicode_version: "7.0", + }, + { + emoji: "🫑", + aliases: ["bell_pepper"], + tags: [], + category: "Food & Drink", + description: "bell pepper", + unicode_version: "13.0", + }, + { + emoji: "🥒", + aliases: ["cucumber"], + tags: [], + category: "Food & Drink", + description: "cucumber", + unicode_version: "9.0", + }, + { + emoji: "🥬", + aliases: ["leafy_green"], + tags: [], + category: "Food & Drink", + description: "leafy green", + unicode_version: "11.0", + }, + { + emoji: "🥦", + aliases: ["broccoli"], + tags: [], + category: "Food & Drink", + description: "broccoli", + unicode_version: "11.0", + }, + { + emoji: "🧄", + aliases: ["garlic"], + tags: [], + category: "Food & Drink", + description: "garlic", + unicode_version: "12.0", + }, + { + emoji: "🧅", + aliases: ["onion"], + tags: [], + category: "Food & Drink", + description: "onion", + unicode_version: "12.0", + }, + { + emoji: "🍄", + aliases: ["mushroom"], + tags: [], + category: "Food & Drink", + description: "mushroom", + unicode_version: "6.0", + }, + { + emoji: "🥜", + aliases: ["peanuts"], + tags: [], + category: "Food & Drink", + description: "peanuts", + unicode_version: "9.0", + }, + { + emoji: "🌰", + aliases: ["chestnut"], + tags: [], + category: "Food & Drink", + description: "chestnut", + unicode_version: "6.0", + }, + { + emoji: "🍞", + aliases: ["bread"], + tags: ["toast"], + category: "Food & Drink", + description: "bread", + unicode_version: "6.0", + }, + { + emoji: "🥐", + aliases: ["croissant"], + tags: [], + category: "Food & Drink", + description: "croissant", + unicode_version: "9.0", + }, + { + emoji: "🥖", + aliases: ["baguette_bread"], + tags: [], + category: "Food & Drink", + description: "baguette bread", + unicode_version: "9.0", + }, + { + emoji: "🫓", + aliases: ["flatbread"], + tags: [], + category: "Food & Drink", + description: "flatbread", + unicode_version: "13.0", + }, + { + emoji: "🥨", + aliases: ["pretzel"], + tags: [], + category: "Food & Drink", + description: "pretzel", + unicode_version: "11.0", + }, + { + emoji: "🥯", + aliases: ["bagel"], + tags: [], + category: "Food & Drink", + description: "bagel", + unicode_version: "11.0", + }, + { + emoji: "🥞", + aliases: ["pancakes"], + tags: [], + category: "Food & Drink", + description: "pancakes", + unicode_version: "9.0", + }, + { + emoji: "🧇", + aliases: ["waffle"], + tags: [], + category: "Food & Drink", + description: "waffle", + unicode_version: "12.0", + }, + { + emoji: "🧀", + aliases: ["cheese"], + tags: [], + category: "Food & Drink", + description: "cheese wedge", + unicode_version: "8.0", + }, + { + emoji: "🍖", + aliases: ["meat_on_bone"], + tags: [], + category: "Food & Drink", + description: "meat on bone", + unicode_version: "6.0", + }, + { + emoji: "🍗", + aliases: ["poultry_leg"], + tags: ["meat", "chicken"], + category: "Food & Drink", + description: "poultry leg", + unicode_version: "6.0", + }, + { + emoji: "🥩", + aliases: ["cut_of_meat"], + tags: [], + category: "Food & Drink", + description: "cut of meat", + unicode_version: "11.0", + }, + { + emoji: "🥓", + aliases: ["bacon"], + tags: [], + category: "Food & Drink", + description: "bacon", + unicode_version: "9.0", + }, + { + emoji: "🍔", + aliases: ["hamburger"], + tags: ["burger"], + category: "Food & Drink", + description: "hamburger", + unicode_version: "6.0", + }, + { + emoji: "🍟", + aliases: ["fries"], + tags: [], + category: "Food & Drink", + description: "french fries", + unicode_version: "6.0", + }, + { + emoji: "🍕", + aliases: ["pizza"], + tags: [], + category: "Food & Drink", + description: "pizza", + unicode_version: "6.0", + }, + { + emoji: "🌭", + aliases: ["hotdog"], + tags: [], + category: "Food & Drink", + description: "hot dog", + unicode_version: "8.0", + }, + { + emoji: "🥪", + aliases: ["sandwich"], + tags: [], + category: "Food & Drink", + description: "sandwich", + unicode_version: "11.0", + }, + { + emoji: "🌮", + aliases: ["taco"], + tags: [], + category: "Food & Drink", + description: "taco", + unicode_version: "8.0", + }, + { + emoji: "🌯", + aliases: ["burrito"], + tags: [], + category: "Food & Drink", + description: "burrito", + unicode_version: "8.0", + }, + { + emoji: "🫔", + aliases: ["tamale"], + tags: [], + category: "Food & Drink", + description: "tamale", + unicode_version: "13.0", + }, + { + emoji: "🥙", + aliases: ["stuffed_flatbread"], + tags: [], + category: "Food & Drink", + description: "stuffed flatbread", + unicode_version: "9.0", + }, + { + emoji: "🧆", + aliases: ["falafel"], + tags: [], + category: "Food & Drink", + description: "falafel", + unicode_version: "12.0", + }, + { + emoji: "🥚", + aliases: ["egg"], + tags: [], + category: "Food & Drink", + description: "egg", + unicode_version: "9.0", + }, + { + emoji: "🍳", + aliases: ["fried_egg"], + tags: ["breakfast"], + category: "Food & Drink", + description: "cooking", + unicode_version: "6.0", + }, + { + emoji: "🥘", + aliases: ["shallow_pan_of_food"], + tags: ["paella", "curry"], + category: "Food & Drink", + description: "shallow pan of food", + unicode_version: "", + }, + { + emoji: "🍲", + aliases: ["stew"], + tags: [], + category: "Food & Drink", + description: "pot of food", + unicode_version: "6.0", + }, + { + emoji: "🫕", + aliases: ["fondue"], + tags: [], + category: "Food & Drink", + description: "fondue", + unicode_version: "13.0", + }, + { + emoji: "🥣", + aliases: ["bowl_with_spoon"], + tags: [], + category: "Food & Drink", + description: "bowl with spoon", + unicode_version: "11.0", + }, + { + emoji: "🥗", + aliases: ["green_salad"], + tags: [], + category: "Food & Drink", + description: "green salad", + unicode_version: "9.0", + }, + { + emoji: "🍿", + aliases: ["popcorn"], + tags: [], + category: "Food & Drink", + description: "popcorn", + unicode_version: "8.0", + }, + { + emoji: "🧈", + aliases: ["butter"], + tags: [], + category: "Food & Drink", + description: "butter", + unicode_version: "12.0", + }, + { + emoji: "🧂", + aliases: ["salt"], + tags: [], + category: "Food & Drink", + description: "salt", + unicode_version: "11.0", + }, + { + emoji: "🥫", + aliases: ["canned_food"], + tags: [], + category: "Food & Drink", + description: "canned food", + unicode_version: "11.0", + }, + { + emoji: "🍱", + aliases: ["bento"], + tags: [], + category: "Food & Drink", + description: "bento box", + unicode_version: "6.0", + }, + { + emoji: "🍘", + aliases: ["rice_cracker"], + tags: [], + category: "Food & Drink", + description: "rice cracker", + unicode_version: "6.0", + }, + { + emoji: "🍙", + aliases: ["rice_ball"], + tags: [], + category: "Food & Drink", + description: "rice ball", + unicode_version: "6.0", + }, + { + emoji: "🍚", + aliases: ["rice"], + tags: [], + category: "Food & Drink", + description: "cooked rice", + unicode_version: "6.0", + }, + { + emoji: "🍛", + aliases: ["curry"], + tags: [], + category: "Food & Drink", + description: "curry rice", + unicode_version: "6.0", + }, + { + emoji: "🍜", + aliases: ["ramen"], + tags: ["noodle"], + category: "Food & Drink", + description: "steaming bowl", + unicode_version: "6.0", + }, + { + emoji: "🍝", + aliases: ["spaghetti"], + tags: ["pasta"], + category: "Food & Drink", + description: "spaghetti", + unicode_version: "6.0", + }, + { + emoji: "🍠", + aliases: ["sweet_potato"], + tags: [], + category: "Food & Drink", + description: "roasted sweet potato", + unicode_version: "6.0", + }, + { + emoji: "🍢", + aliases: ["oden"], + tags: [], + category: "Food & Drink", + description: "oden", + unicode_version: "6.0", + }, + { + emoji: "🍣", + aliases: ["sushi"], + tags: [], + category: "Food & Drink", + description: "sushi", + unicode_version: "6.0", + }, + { + emoji: "🍤", + aliases: ["fried_shrimp"], + tags: ["tempura"], + category: "Food & Drink", + description: "fried shrimp", + unicode_version: "6.0", + }, + { + emoji: "🍥", + aliases: ["fish_cake"], + tags: [], + category: "Food & Drink", + description: "fish cake with swirl", + unicode_version: "6.0", + }, + { + emoji: "🥮", + aliases: ["moon_cake"], + tags: [], + category: "Food & Drink", + description: "moon cake", + unicode_version: "11.0", + }, + { + emoji: "🍡", + aliases: ["dango"], + tags: [], + category: "Food & Drink", + description: "dango", + unicode_version: "6.0", + }, + { + emoji: "🥟", + aliases: ["dumpling"], + tags: [], + category: "Food & Drink", + description: "dumpling", + unicode_version: "11.0", + }, + { + emoji: "🥠", + aliases: ["fortune_cookie"], + tags: [], + category: "Food & Drink", + description: "fortune cookie", + unicode_version: "11.0", + }, + { + emoji: "🥡", + aliases: ["takeout_box"], + tags: [], + category: "Food & Drink", + description: "takeout box", + unicode_version: "11.0", + }, + { + emoji: "🦀", + aliases: ["crab"], + tags: [], + category: "Food & Drink", + description: "crab", + unicode_version: "8.0", + }, + { + emoji: "🦞", + aliases: ["lobster"], + tags: [], + category: "Food & Drink", + description: "lobster", + unicode_version: "11.0", + }, + { + emoji: "🦐", + aliases: ["shrimp"], + tags: [], + category: "Food & Drink", + description: "shrimp", + unicode_version: "9.0", + }, + { + emoji: "🦑", + aliases: ["squid"], + tags: [], + category: "Food & Drink", + description: "squid", + unicode_version: "9.0", + }, + { + emoji: "🦪", + aliases: ["oyster"], + tags: [], + category: "Food & Drink", + description: "oyster", + unicode_version: "12.0", + }, + { + emoji: "🍦", + aliases: ["icecream"], + tags: [], + category: "Food & Drink", + description: "soft ice cream", + unicode_version: "6.0", + }, + { + emoji: "🍧", + aliases: ["shaved_ice"], + tags: [], + category: "Food & Drink", + description: "shaved ice", + unicode_version: "6.0", + }, + { + emoji: "🍨", + aliases: ["ice_cream"], + tags: [], + category: "Food & Drink", + description: "ice cream", + unicode_version: "6.0", + }, + { + emoji: "🍩", + aliases: ["doughnut"], + tags: [], + category: "Food & Drink", + description: "doughnut", + unicode_version: "6.0", + }, + { + emoji: "🍪", + aliases: ["cookie"], + tags: [], + category: "Food & Drink", + description: "cookie", + unicode_version: "6.0", + }, + { + emoji: "🎂", + aliases: ["birthday"], + tags: ["party"], + category: "Food & Drink", + description: "birthday cake", + unicode_version: "6.0", + }, + { + emoji: "🍰", + aliases: ["cake"], + tags: ["dessert"], + category: "Food & Drink", + description: "shortcake", + unicode_version: "6.0", + }, + { + emoji: "🧁", + aliases: ["cupcake"], + tags: [], + category: "Food & Drink", + description: "cupcake", + unicode_version: "11.0", + }, + { + emoji: "🥧", + aliases: ["pie"], + tags: [], + category: "Food & Drink", + description: "pie", + unicode_version: "11.0", + }, + { + emoji: "🍫", + aliases: ["chocolate_bar"], + tags: [], + category: "Food & Drink", + description: "chocolate bar", + unicode_version: "6.0", + }, + { + emoji: "🍬", + aliases: ["candy"], + tags: ["sweet"], + category: "Food & Drink", + description: "candy", + unicode_version: "6.0", + }, + { + emoji: "🍭", + aliases: ["lollipop"], + tags: [], + category: "Food & Drink", + description: "lollipop", + unicode_version: "6.0", + }, + { + emoji: "🍮", + aliases: ["custard"], + tags: [], + category: "Food & Drink", + description: "custard", + unicode_version: "6.0", + }, + { + emoji: "🍯", + aliases: ["honey_pot"], + tags: [], + category: "Food & Drink", + description: "honey pot", + unicode_version: "6.0", + }, + { + emoji: "🍼", + aliases: ["baby_bottle"], + tags: ["milk"], + category: "Food & Drink", + description: "baby bottle", + unicode_version: "6.0", + }, + { + emoji: "🥛", + aliases: ["milk_glass"], + tags: [], + category: "Food & Drink", + description: "glass of milk", + unicode_version: "9.0", + }, + { + emoji: "☕", + aliases: ["coffee"], + tags: ["cafe", "espresso"], + category: "Food & Drink", + description: "hot beverage", + unicode_version: "4.0", + }, + { + emoji: "🫖", + aliases: ["teapot"], + tags: [], + category: "Food & Drink", + description: "teapot", + unicode_version: "13.0", + }, + { + emoji: "🍵", + aliases: ["tea"], + tags: ["green", "breakfast"], + category: "Food & Drink", + description: "teacup without handle", + unicode_version: "6.0", + }, + { + emoji: "🍶", + aliases: ["sake"], + tags: [], + category: "Food & Drink", + description: "sake", + unicode_version: "6.0", + }, + { + emoji: "🍾", + aliases: ["champagne"], + tags: ["bottle", "bubbly", "celebration"], + category: "Food & Drink", + description: "bottle with popping cork", + unicode_version: "8.0", + }, + { + emoji: "🍷", + aliases: ["wine_glass"], + tags: [], + category: "Food & Drink", + description: "wine glass", + unicode_version: "6.0", + }, + { + emoji: "🍸", + aliases: ["cocktail"], + tags: ["drink"], + category: "Food & Drink", + description: "cocktail glass", + unicode_version: "6.0", + }, + { + emoji: "🍹", + aliases: ["tropical_drink"], + tags: ["summer", "vacation"], + category: "Food & Drink", + description: "tropical drink", + unicode_version: "6.0", + }, + { + emoji: "🍺", + aliases: ["beer"], + tags: ["drink"], + category: "Food & Drink", + description: "beer mug", + unicode_version: "6.0", + }, + { + emoji: "🍻", + aliases: ["beers"], + tags: ["drinks"], + category: "Food & Drink", + description: "clinking beer mugs", + unicode_version: "6.0", + }, + { + emoji: "🥂", + aliases: ["clinking_glasses"], + tags: ["cheers", "toast"], + category: "Food & Drink", + description: "clinking glasses", + unicode_version: "9.0", + }, + { + emoji: "🥃", + aliases: ["tumbler_glass"], + tags: ["whisky"], + category: "Food & Drink", + description: "tumbler glass", + unicode_version: "9.0", + }, + { + emoji: "🥤", + aliases: ["cup_with_straw"], + tags: [], + category: "Food & Drink", + description: "cup with straw", + unicode_version: "11.0", + }, + { + emoji: "🧋", + aliases: ["bubble_tea"], + tags: [], + category: "Food & Drink", + description: "bubble tea", + unicode_version: "13.0", + }, + { + emoji: "🧃", + aliases: ["beverage_box"], + tags: [], + category: "Food & Drink", + description: "beverage box", + unicode_version: "12.0", + }, + { + emoji: "🧉", + aliases: ["mate"], + tags: [], + category: "Food & Drink", + description: "mate", + unicode_version: "12.0", + }, + { + emoji: "🧊", + aliases: ["ice_cube"], + tags: [], + category: "Food & Drink", + description: "ice", + unicode_version: "12.0", + }, + { + emoji: "🥢", + aliases: ["chopsticks"], + tags: [], + category: "Food & Drink", + description: "chopsticks", + unicode_version: "11.0", + }, + { + emoji: "🍽️", + aliases: ["plate_with_cutlery"], + tags: ["dining", "dinner"], + category: "Food & Drink", + description: "fork and knife with plate", + unicode_version: "7.0", + }, + { + emoji: "🍴", + aliases: ["fork_and_knife"], + tags: ["cutlery"], + category: "Food & Drink", + description: "fork and knife", + unicode_version: "6.0", + }, + { + emoji: "🥄", + aliases: ["spoon"], + tags: [], + category: "Food & Drink", + description: "spoon", + unicode_version: "9.0", + }, + { + emoji: "🔪", + aliases: ["hocho", "knife"], + tags: ["cut", "chop"], + category: "Food & Drink", + description: "kitchen knife", + unicode_version: "6.0", + }, + { + emoji: "🏺", + aliases: ["amphora"], + tags: [], + category: "Food & Drink", + description: "amphora", + unicode_version: "8.0", + }, + { + emoji: "🌍", + aliases: ["earth_africa"], + tags: ["globe", "world", "international"], + category: "Travel & Places", + description: "globe showing Europe-Africa", + unicode_version: "6.0", + }, + { + emoji: "🌎", + aliases: ["earth_americas"], + tags: ["globe", "world", "international"], + category: "Travel & Places", + description: "globe showing Americas", + unicode_version: "6.0", + }, + { + emoji: "🌏", + aliases: ["earth_asia"], + tags: ["globe", "world", "international"], + category: "Travel & Places", + description: "globe showing Asia-Australia", + unicode_version: "6.0", + }, + { + emoji: "🌐", + aliases: ["globe_with_meridians"], + tags: ["world", "global", "international"], + category: "Travel & Places", + description: "globe with meridians", + unicode_version: "6.0", + }, + { + emoji: "🗺️", + aliases: ["world_map"], + tags: ["travel"], + category: "Travel & Places", + description: "world map", + unicode_version: "7.0", + }, + { + emoji: "🗾", + aliases: ["japan"], + tags: [], + category: "Travel & Places", + description: "map of Japan", + unicode_version: "6.0", + }, + { + emoji: "🧭", + aliases: ["compass"], + tags: [], + category: "Travel & Places", + description: "compass", + unicode_version: "11.0", + }, + { + emoji: "🏔️", + aliases: ["mountain_snow"], + tags: [], + category: "Travel & Places", + description: "snow-capped mountain", + unicode_version: "7.0", + }, + { + emoji: "⛰️", + aliases: ["mountain"], + tags: [], + category: "Travel & Places", + description: "mountain", + unicode_version: "5.2", + }, + { + emoji: "🌋", + aliases: ["volcano"], + tags: [], + category: "Travel & Places", + description: "volcano", + unicode_version: "6.0", + }, + { + emoji: "🗻", + aliases: ["mount_fuji"], + tags: [], + category: "Travel & Places", + description: "mount fuji", + unicode_version: "6.0", + }, + { + emoji: "🏕️", + aliases: ["camping"], + tags: [], + category: "Travel & Places", + description: "camping", + unicode_version: "7.0", + }, + { + emoji: "🏖️", + aliases: ["beach_umbrella"], + tags: [], + category: "Travel & Places", + description: "beach with umbrella", + unicode_version: "7.0", + }, + { + emoji: "🏜️", + aliases: ["desert"], + tags: [], + category: "Travel & Places", + description: "desert", + unicode_version: "7.0", + }, + { + emoji: "🏝️", + aliases: ["desert_island"], + tags: [], + category: "Travel & Places", + description: "desert island", + unicode_version: "7.0", + }, + { + emoji: "🏞️", + aliases: ["national_park"], + tags: [], + category: "Travel & Places", + description: "national park", + unicode_version: "7.0", + }, + { + emoji: "🏟️", + aliases: ["stadium"], + tags: [], + category: "Travel & Places", + description: "stadium", + unicode_version: "7.0", + }, + { + emoji: "🏛️", + aliases: ["classical_building"], + tags: [], + category: "Travel & Places", + description: "classical building", + unicode_version: "7.0", + }, + { + emoji: "🏗️", + aliases: ["building_construction"], + tags: [], + category: "Travel & Places", + description: "building construction", + unicode_version: "7.0", + }, + { + emoji: "🧱", + aliases: ["bricks"], + tags: [], + category: "Travel & Places", + description: "brick", + unicode_version: "11.0", + }, + { + emoji: "🪨", + aliases: ["rock"], + tags: [], + category: "Travel & Places", + description: "rock", + unicode_version: "13.0", + }, + { + emoji: "🪵", + aliases: ["wood"], + tags: [], + category: "Travel & Places", + description: "wood", + unicode_version: "13.0", + }, + { + emoji: "🛖", + aliases: ["hut"], + tags: [], + category: "Travel & Places", + description: "hut", + unicode_version: "13.0", + }, + { + emoji: "🏘️", + aliases: ["houses"], + tags: [], + category: "Travel & Places", + description: "houses", + unicode_version: "7.0", + }, + { + emoji: "🏚️", + aliases: ["derelict_house"], + tags: [], + category: "Travel & Places", + description: "derelict house", + unicode_version: "7.0", + }, + { + emoji: "🏠", + aliases: ["house"], + tags: [], + category: "Travel & Places", + description: "house", + unicode_version: "6.0", + }, + { + emoji: "🏡", + aliases: ["house_with_garden"], + tags: [], + category: "Travel & Places", + description: "house with garden", + unicode_version: "6.0", + }, + { + emoji: "🏢", + aliases: ["office"], + tags: [], + category: "Travel & Places", + description: "office building", + unicode_version: "6.0", + }, + { + emoji: "🏣", + aliases: ["post_office"], + tags: [], + category: "Travel & Places", + description: "Japanese post office", + unicode_version: "6.0", + }, + { + emoji: "🏤", + aliases: ["european_post_office"], + tags: [], + category: "Travel & Places", + description: "post office", + unicode_version: "6.0", + }, + { + emoji: "🏥", + aliases: ["hospital"], + tags: [], + category: "Travel & Places", + description: "hospital", + unicode_version: "6.0", + }, + { + emoji: "🏦", + aliases: ["bank"], + tags: [], + category: "Travel & Places", + description: "bank", + unicode_version: "6.0", + }, + { + emoji: "🏨", + aliases: ["hotel"], + tags: [], + category: "Travel & Places", + description: "hotel", + unicode_version: "6.0", + }, + { + emoji: "🏩", + aliases: ["love_hotel"], + tags: [], + category: "Travel & Places", + description: "love hotel", + unicode_version: "6.0", + }, + { + emoji: "🏪", + aliases: ["convenience_store"], + tags: [], + category: "Travel & Places", + description: "convenience store", + unicode_version: "6.0", + }, + { + emoji: "🏫", + aliases: ["school"], + tags: [], + category: "Travel & Places", + description: "school", + unicode_version: "6.0", + }, + { + emoji: "🏬", + aliases: ["department_store"], + tags: [], + category: "Travel & Places", + description: "department store", + unicode_version: "6.0", + }, + { + emoji: "🏭", + aliases: ["factory"], + tags: [], + category: "Travel & Places", + description: "factory", + unicode_version: "6.0", + }, + { + emoji: "🏯", + aliases: ["japanese_castle"], + tags: [], + category: "Travel & Places", + description: "Japanese castle", + unicode_version: "6.0", + }, + { + emoji: "🏰", + aliases: ["european_castle"], + tags: [], + category: "Travel & Places", + description: "castle", + unicode_version: "6.0", + }, + { + emoji: "💒", + aliases: ["wedding"], + tags: ["marriage"], + category: "Travel & Places", + description: "wedding", + unicode_version: "6.0", + }, + { + emoji: "🗼", + aliases: ["tokyo_tower"], + tags: [], + category: "Travel & Places", + description: "Tokyo tower", + unicode_version: "6.0", + }, + { + emoji: "🗽", + aliases: ["statue_of_liberty"], + tags: [], + category: "Travel & Places", + description: "Statue of Liberty", + unicode_version: "6.0", + }, + { + emoji: "⛪", + aliases: ["church"], + tags: [], + category: "Travel & Places", + description: "church", + unicode_version: "5.2", + }, + { + emoji: "🕌", + aliases: ["mosque"], + tags: [], + category: "Travel & Places", + description: "mosque", + unicode_version: "8.0", + }, + { + emoji: "🛕", + aliases: ["hindu_temple"], + tags: [], + category: "Travel & Places", + description: "hindu temple", + unicode_version: "12.0", + }, + { + emoji: "🕍", + aliases: ["synagogue"], + tags: [], + category: "Travel & Places", + description: "synagogue", + unicode_version: "8.0", + }, + { + emoji: "⛩️", + aliases: ["shinto_shrine"], + tags: [], + category: "Travel & Places", + description: "shinto shrine", + unicode_version: "5.2", + }, + { + emoji: "🕋", + aliases: ["kaaba"], + tags: [], + category: "Travel & Places", + description: "kaaba", + unicode_version: "8.0", + }, + { + emoji: "⛲", + aliases: ["fountain"], + tags: [], + category: "Travel & Places", + description: "fountain", + unicode_version: "5.2", + }, + { + emoji: "⛺", + aliases: ["tent"], + tags: ["camping"], + category: "Travel & Places", + description: "tent", + unicode_version: "5.2", + }, + { + emoji: "🌁", + aliases: ["foggy"], + tags: ["karl"], + category: "Travel & Places", + description: "foggy", + unicode_version: "6.0", + }, + { + emoji: "🌃", + aliases: ["night_with_stars"], + tags: [], + category: "Travel & Places", + description: "night with stars", + unicode_version: "6.0", + }, + { + emoji: "🏙️", + aliases: ["cityscape"], + tags: ["skyline"], + category: "Travel & Places", + description: "cityscape", + unicode_version: "7.0", + }, + { + emoji: "🌄", + aliases: ["sunrise_over_mountains"], + tags: [], + category: "Travel & Places", + description: "sunrise over mountains", + unicode_version: "6.0", + }, + { + emoji: "🌅", + aliases: ["sunrise"], + tags: [], + category: "Travel & Places", + description: "sunrise", + unicode_version: "6.0", + }, + { + emoji: "🌆", + aliases: ["city_sunset"], + tags: [], + category: "Travel & Places", + description: "cityscape at dusk", + unicode_version: "6.0", + }, + { + emoji: "🌇", + aliases: ["city_sunrise"], + tags: [], + category: "Travel & Places", + description: "sunset", + unicode_version: "6.0", + }, + { + emoji: "🌉", + aliases: ["bridge_at_night"], + tags: [], + category: "Travel & Places", + description: "bridge at night", + unicode_version: "6.0", + }, + { + emoji: "♨️", + aliases: ["hotsprings"], + tags: [], + category: "Travel & Places", + description: "hot springs", + unicode_version: "", + }, + { + emoji: "🎠", + aliases: ["carousel_horse"], + tags: [], + category: "Travel & Places", + description: "carousel horse", + unicode_version: "6.0", + }, + { + emoji: "🎡", + aliases: ["ferris_wheel"], + tags: [], + category: "Travel & Places", + description: "ferris wheel", + unicode_version: "6.0", + }, + { + emoji: "🎢", + aliases: ["roller_coaster"], + tags: [], + category: "Travel & Places", + description: "roller coaster", + unicode_version: "6.0", + }, + { + emoji: "💈", + aliases: ["barber"], + tags: [], + category: "Travel & Places", + description: "barber pole", + unicode_version: "6.0", + }, + { + emoji: "🎪", + aliases: ["circus_tent"], + tags: [], + category: "Travel & Places", + description: "circus tent", + unicode_version: "6.0", + }, + { + emoji: "🚂", + aliases: ["steam_locomotive"], + tags: ["train"], + category: "Travel & Places", + description: "locomotive", + unicode_version: "6.0", + }, + { + emoji: "🚃", + aliases: ["railway_car"], + tags: [], + category: "Travel & Places", + description: "railway car", + unicode_version: "6.0", + }, + { + emoji: "🚄", + aliases: ["bullettrain_side"], + tags: ["train"], + category: "Travel & Places", + description: "high-speed train", + unicode_version: "6.0", + }, + { + emoji: "🚅", + aliases: ["bullettrain_front"], + tags: ["train"], + category: "Travel & Places", + description: "bullet train", + unicode_version: "6.0", + }, + { + emoji: "🚆", + aliases: ["train2"], + tags: [], + category: "Travel & Places", + description: "train", + unicode_version: "6.0", + }, + { + emoji: "🚇", + aliases: ["metro"], + tags: [], + category: "Travel & Places", + description: "metro", + unicode_version: "6.0", + }, + { + emoji: "🚈", + aliases: ["light_rail"], + tags: [], + category: "Travel & Places", + description: "light rail", + unicode_version: "6.0", + }, + { + emoji: "🚉", + aliases: ["station"], + tags: [], + category: "Travel & Places", + description: "station", + unicode_version: "6.0", + }, + { + emoji: "🚊", + aliases: ["tram"], + tags: [], + category: "Travel & Places", + description: "tram", + unicode_version: "6.0", + }, + { + emoji: "🚝", + aliases: ["monorail"], + tags: [], + category: "Travel & Places", + description: "monorail", + unicode_version: "6.0", + }, + { + emoji: "🚞", + aliases: ["mountain_railway"], + tags: [], + category: "Travel & Places", + description: "mountain railway", + unicode_version: "6.0", + }, + { + emoji: "🚋", + aliases: ["train"], + tags: [], + category: "Travel & Places", + description: "tram car", + unicode_version: "6.0", + }, + { + emoji: "🚌", + aliases: ["bus"], + tags: [], + category: "Travel & Places", + description: "bus", + unicode_version: "6.0", + }, + { + emoji: "🚍", + aliases: ["oncoming_bus"], + tags: [], + category: "Travel & Places", + description: "oncoming bus", + unicode_version: "6.0", + }, + { + emoji: "🚎", + aliases: ["trolleybus"], + tags: [], + category: "Travel & Places", + description: "trolleybus", + unicode_version: "6.0", + }, + { + emoji: "🚐", + aliases: ["minibus"], + tags: [], + category: "Travel & Places", + description: "minibus", + unicode_version: "6.0", + }, + { + emoji: "🚑", + aliases: ["ambulance"], + tags: [], + category: "Travel & Places", + description: "ambulance", + unicode_version: "6.0", + }, + { + emoji: "🚒", + aliases: ["fire_engine"], + tags: [], + category: "Travel & Places", + description: "fire engine", + unicode_version: "6.0", + }, + { + emoji: "🚓", + aliases: ["police_car"], + tags: [], + category: "Travel & Places", + description: "police car", + unicode_version: "6.0", + }, + { + emoji: "🚔", + aliases: ["oncoming_police_car"], + tags: [], + category: "Travel & Places", + description: "oncoming police car", + unicode_version: "6.0", + }, + { + emoji: "🚕", + aliases: ["taxi"], + tags: [], + category: "Travel & Places", + description: "taxi", + unicode_version: "6.0", + }, + { + emoji: "🚖", + aliases: ["oncoming_taxi"], + tags: [], + category: "Travel & Places", + description: "oncoming taxi", + unicode_version: "6.0", + }, + { + emoji: "🚗", + aliases: ["car", "red_car"], + tags: [], + category: "Travel & Places", + description: "automobile", + unicode_version: "6.0", + }, + { + emoji: "🚘", + aliases: ["oncoming_automobile"], + tags: [], + category: "Travel & Places", + description: "oncoming automobile", + unicode_version: "6.0", + }, + { + emoji: "🚙", + aliases: ["blue_car"], + tags: [], + category: "Travel & Places", + description: "sport utility vehicle", + unicode_version: "6.0", + }, + { + emoji: "🛻", + aliases: ["pickup_truck"], + tags: [], + category: "Travel & Places", + description: "pickup truck", + unicode_version: "13.0", + }, + { + emoji: "🚚", + aliases: ["truck"], + tags: [], + category: "Travel & Places", + description: "delivery truck", + unicode_version: "6.0", + }, + { + emoji: "🚛", + aliases: ["articulated_lorry"], + tags: [], + category: "Travel & Places", + description: "articulated lorry", + unicode_version: "6.0", + }, + { + emoji: "🚜", + aliases: ["tractor"], + tags: [], + category: "Travel & Places", + description: "tractor", + unicode_version: "6.0", + }, + { + emoji: "🏎️", + aliases: ["racing_car"], + tags: [], + category: "Travel & Places", + description: "racing car", + unicode_version: "7.0", + }, + { + emoji: "🏍️", + aliases: ["motorcycle"], + tags: [], + category: "Travel & Places", + description: "motorcycle", + unicode_version: "7.0", + }, + { + emoji: "🛵", + aliases: ["motor_scooter"], + tags: [], + category: "Travel & Places", + description: "motor scooter", + unicode_version: "9.0", + }, + { + emoji: "🦽", + aliases: ["manual_wheelchair"], + tags: [], + category: "Travel & Places", + description: "manual wheelchair", + unicode_version: "12.0", + }, + { + emoji: "🦼", + aliases: ["motorized_wheelchair"], + tags: [], + category: "Travel & Places", + description: "motorized wheelchair", + unicode_version: "12.0", + }, + { + emoji: "🛺", + aliases: ["auto_rickshaw"], + tags: [], + category: "Travel & Places", + description: "auto rickshaw", + unicode_version: "12.0", + }, + { + emoji: "🚲", + aliases: ["bike"], + tags: ["bicycle"], + category: "Travel & Places", + description: "bicycle", + unicode_version: "6.0", + }, + { + emoji: "🛴", + aliases: ["kick_scooter"], + tags: [], + category: "Travel & Places", + description: "kick scooter", + unicode_version: "9.0", + }, + { + emoji: "🛹", + aliases: ["skateboard"], + tags: [], + category: "Travel & Places", + description: "skateboard", + unicode_version: "11.0", + }, + { + emoji: "🛼", + aliases: ["roller_skate"], + tags: [], + category: "Travel & Places", + description: "roller skate", + unicode_version: "13.0", + }, + { + emoji: "🚏", + aliases: ["busstop"], + tags: [], + category: "Travel & Places", + description: "bus stop", + unicode_version: "6.0", + }, + { + emoji: "🛣️", + aliases: ["motorway"], + tags: [], + category: "Travel & Places", + description: "motorway", + unicode_version: "7.0", + }, + { + emoji: "🛤️", + aliases: ["railway_track"], + tags: [], + category: "Travel & Places", + description: "railway track", + unicode_version: "7.0", + }, + { + emoji: "🛢️", + aliases: ["oil_drum"], + tags: [], + category: "Travel & Places", + description: "oil drum", + unicode_version: "7.0", + }, + { + emoji: "⛽", + aliases: ["fuelpump"], + tags: [], + category: "Travel & Places", + description: "fuel pump", + unicode_version: "5.2", + }, + { + emoji: "🚨", + aliases: ["rotating_light"], + tags: ["911", "emergency"], + category: "Travel & Places", + description: "police car light", + unicode_version: "6.0", + }, + { + emoji: "🚥", + aliases: ["traffic_light"], + tags: [], + category: "Travel & Places", + description: "horizontal traffic light", + unicode_version: "6.0", + }, + { + emoji: "🚦", + aliases: ["vertical_traffic_light"], + tags: ["semaphore"], + category: "Travel & Places", + description: "vertical traffic light", + unicode_version: "6.0", + }, + { + emoji: "🛑", + aliases: ["stop_sign"], + tags: [], + category: "Travel & Places", + description: "stop sign", + unicode_version: "9.0", + }, + { + emoji: "🚧", + aliases: ["construction"], + tags: ["wip"], + category: "Travel & Places", + description: "construction", + unicode_version: "6.0", + }, + { + emoji: "⚓", + aliases: ["anchor"], + tags: ["ship"], + category: "Travel & Places", + description: "anchor", + unicode_version: "4.1", + }, + { + emoji: "⛵", + aliases: ["boat", "sailboat"], + tags: [], + category: "Travel & Places", + description: "sailboat", + unicode_version: "5.2", + }, + { + emoji: "🛶", + aliases: ["canoe"], + tags: [], + category: "Travel & Places", + description: "canoe", + unicode_version: "9.0", + }, + { + emoji: "🚤", + aliases: ["speedboat"], + tags: ["ship"], + category: "Travel & Places", + description: "speedboat", + unicode_version: "6.0", + }, + { + emoji: "🛳️", + aliases: ["passenger_ship"], + tags: ["cruise"], + category: "Travel & Places", + description: "passenger ship", + unicode_version: "7.0", + }, + { + emoji: "⛴️", + aliases: ["ferry"], + tags: [], + category: "Travel & Places", + description: "ferry", + unicode_version: "5.2", + }, + { + emoji: "🛥️", + aliases: ["motor_boat"], + tags: [], + category: "Travel & Places", + description: "motor boat", + unicode_version: "7.0", + }, + { + emoji: "🚢", + aliases: ["ship"], + tags: [], + category: "Travel & Places", + description: "ship", + unicode_version: "6.0", + }, + { + emoji: "✈️", + aliases: ["airplane"], + tags: ["flight"], + category: "Travel & Places", + description: "airplane", + unicode_version: "", + }, + { + emoji: "🛩️", + aliases: ["small_airplane"], + tags: ["flight"], + category: "Travel & Places", + description: "small airplane", + unicode_version: "7.0", + }, + { + emoji: "🛫", + aliases: ["flight_departure"], + tags: [], + category: "Travel & Places", + description: "airplane departure", + unicode_version: "7.0", + }, + { + emoji: "🛬", + aliases: ["flight_arrival"], + tags: [], + category: "Travel & Places", + description: "airplane arrival", + unicode_version: "7.0", + }, + { + emoji: "🪂", + aliases: ["parachute"], + tags: [], + category: "Travel & Places", + description: "parachute", + unicode_version: "12.0", + }, + { + emoji: "💺", + aliases: ["seat"], + tags: [], + category: "Travel & Places", + description: "seat", + unicode_version: "6.0", + }, + { + emoji: "🚁", + aliases: ["helicopter"], + tags: [], + category: "Travel & Places", + description: "helicopter", + unicode_version: "6.0", + }, + { + emoji: "🚟", + aliases: ["suspension_railway"], + tags: [], + category: "Travel & Places", + description: "suspension railway", + unicode_version: "6.0", + }, + { + emoji: "🚠", + aliases: ["mountain_cableway"], + tags: [], + category: "Travel & Places", + description: "mountain cableway", + unicode_version: "6.0", + }, + { + emoji: "🚡", + aliases: ["aerial_tramway"], + tags: [], + category: "Travel & Places", + description: "aerial tramway", + unicode_version: "6.0", + }, + { + emoji: "🛰️", + aliases: ["artificial_satellite"], + tags: ["orbit", "space"], + category: "Travel & Places", + description: "satellite", + unicode_version: "7.0", + }, + { + emoji: "🚀", + aliases: ["rocket"], + tags: ["ship", "launch"], + category: "Travel & Places", + description: "rocket", + unicode_version: "6.0", + }, + { + emoji: "🛸", + aliases: ["flying_saucer"], + tags: ["ufo"], + category: "Travel & Places", + description: "flying saucer", + unicode_version: "11.0", + }, + { + emoji: "🛎️", + aliases: ["bellhop_bell"], + tags: [], + category: "Travel & Places", + description: "bellhop bell", + unicode_version: "7.0", + }, + { + emoji: "🧳", + aliases: ["luggage"], + tags: [], + category: "Travel & Places", + description: "luggage", + unicode_version: "11.0", + }, + { + emoji: "⌛", + aliases: ["hourglass"], + tags: ["time"], + category: "Travel & Places", + description: "hourglass done", + unicode_version: "", + }, + { + emoji: "⏳", + aliases: ["hourglass_flowing_sand"], + tags: ["time"], + category: "Travel & Places", + description: "hourglass not done", + unicode_version: "6.0", + }, + { + emoji: "⌚", + aliases: ["watch"], + tags: ["time"], + category: "Travel & Places", + description: "watch", + unicode_version: "", + }, + { + emoji: "⏰", + aliases: ["alarm_clock"], + tags: ["morning"], + category: "Travel & Places", + description: "alarm clock", + unicode_version: "6.0", + }, + { + emoji: "⏱️", + aliases: ["stopwatch"], + tags: [], + category: "Travel & Places", + description: "stopwatch", + unicode_version: "6.0", + }, + { + emoji: "⏲️", + aliases: ["timer_clock"], + tags: [], + category: "Travel & Places", + description: "timer clock", + unicode_version: "6.0", + }, + { + emoji: "🕰️", + aliases: ["mantelpiece_clock"], + tags: [], + category: "Travel & Places", + description: "mantelpiece clock", + unicode_version: "7.0", + }, + { + emoji: "🕛", + aliases: ["clock12"], + tags: [], + category: "Travel & Places", + description: "twelve o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕧", + aliases: ["clock1230"], + tags: [], + category: "Travel & Places", + description: "twelve-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕐", + aliases: ["clock1"], + tags: [], + category: "Travel & Places", + description: "one o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕜", + aliases: ["clock130"], + tags: [], + category: "Travel & Places", + description: "one-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕑", + aliases: ["clock2"], + tags: [], + category: "Travel & Places", + description: "two o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕝", + aliases: ["clock230"], + tags: [], + category: "Travel & Places", + description: "two-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕒", + aliases: ["clock3"], + tags: [], + category: "Travel & Places", + description: "three o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕞", + aliases: ["clock330"], + tags: [], + category: "Travel & Places", + description: "three-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕓", + aliases: ["clock4"], + tags: [], + category: "Travel & Places", + description: "four o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕟", + aliases: ["clock430"], + tags: [], + category: "Travel & Places", + description: "four-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕔", + aliases: ["clock5"], + tags: [], + category: "Travel & Places", + description: "five o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕠", + aliases: ["clock530"], + tags: [], + category: "Travel & Places", + description: "five-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕕", + aliases: ["clock6"], + tags: [], + category: "Travel & Places", + description: "six o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕡", + aliases: ["clock630"], + tags: [], + category: "Travel & Places", + description: "six-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕖", + aliases: ["clock7"], + tags: [], + category: "Travel & Places", + description: "seven o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕢", + aliases: ["clock730"], + tags: [], + category: "Travel & Places", + description: "seven-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕗", + aliases: ["clock8"], + tags: [], + category: "Travel & Places", + description: "eight o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕣", + aliases: ["clock830"], + tags: [], + category: "Travel & Places", + description: "eight-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕘", + aliases: ["clock9"], + tags: [], + category: "Travel & Places", + description: "nine o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕤", + aliases: ["clock930"], + tags: [], + category: "Travel & Places", + description: "nine-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕙", + aliases: ["clock10"], + tags: [], + category: "Travel & Places", + description: "ten o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕥", + aliases: ["clock1030"], + tags: [], + category: "Travel & Places", + description: "ten-thirty", + unicode_version: "6.0", + }, + { + emoji: "🕚", + aliases: ["clock11"], + tags: [], + category: "Travel & Places", + description: "eleven o’clock", + unicode_version: "6.0", + }, + { + emoji: "🕦", + aliases: ["clock1130"], + tags: [], + category: "Travel & Places", + description: "eleven-thirty", + unicode_version: "6.0", + }, + { + emoji: "🌑", + aliases: ["new_moon"], + tags: [], + category: "Travel & Places", + description: "new moon", + unicode_version: "6.0", + }, + { + emoji: "🌒", + aliases: ["waxing_crescent_moon"], + tags: [], + category: "Travel & Places", + description: "waxing crescent moon", + unicode_version: "6.0", + }, + { + emoji: "🌓", + aliases: ["first_quarter_moon"], + tags: [], + category: "Travel & Places", + description: "first quarter moon", + unicode_version: "6.0", + }, + { + emoji: "🌔", + aliases: ["moon", "waxing_gibbous_moon"], + tags: [], + category: "Travel & Places", + description: "waxing gibbous moon", + unicode_version: "6.0", + }, + { + emoji: "🌕", + aliases: ["full_moon"], + tags: [], + category: "Travel & Places", + description: "full moon", + unicode_version: "6.0", + }, + { + emoji: "🌖", + aliases: ["waning_gibbous_moon"], + tags: [], + category: "Travel & Places", + description: "waning gibbous moon", + unicode_version: "6.0", + }, + { + emoji: "🌗", + aliases: ["last_quarter_moon"], + tags: [], + category: "Travel & Places", + description: "last quarter moon", + unicode_version: "6.0", + }, + { + emoji: "🌘", + aliases: ["waning_crescent_moon"], + tags: [], + category: "Travel & Places", + description: "waning crescent moon", + unicode_version: "6.0", + }, + { + emoji: "🌙", + aliases: ["crescent_moon"], + tags: ["night"], + category: "Travel & Places", + description: "crescent moon", + unicode_version: "6.0", + }, + { + emoji: "🌚", + aliases: ["new_moon_with_face"], + tags: [], + category: "Travel & Places", + description: "new moon face", + unicode_version: "6.0", + }, + { + emoji: "🌛", + aliases: ["first_quarter_moon_with_face"], + tags: [], + category: "Travel & Places", + description: "first quarter moon face", + unicode_version: "6.0", + }, + { + emoji: "🌜", + aliases: ["last_quarter_moon_with_face"], + tags: [], + category: "Travel & Places", + description: "last quarter moon face", + unicode_version: "6.0", + }, + { + emoji: "🌡️", + aliases: ["thermometer"], + tags: [], + category: "Travel & Places", + description: "thermometer", + unicode_version: "7.0", + }, + { + emoji: "☀️", + aliases: ["sunny"], + tags: ["weather"], + category: "Travel & Places", + description: "sun", + unicode_version: "", + }, + { + emoji: "🌝", + aliases: ["full_moon_with_face"], + tags: [], + category: "Travel & Places", + description: "full moon face", + unicode_version: "6.0", + }, + { + emoji: "🌞", + aliases: ["sun_with_face"], + tags: ["summer"], + category: "Travel & Places", + description: "sun with face", + unicode_version: "6.0", + }, + { + emoji: "🪐", + aliases: ["ringed_planet"], + tags: [], + category: "Travel & Places", + description: "ringed planet", + unicode_version: "12.0", + }, + { + emoji: "⭐", + aliases: ["star"], + tags: [], + category: "Travel & Places", + description: "star", + unicode_version: "5.1", + }, + { + emoji: "🌟", + aliases: ["star2"], + tags: [], + category: "Travel & Places", + description: "glowing star", + unicode_version: "6.0", + }, + { + emoji: "🌠", + aliases: ["stars"], + tags: [], + category: "Travel & Places", + description: "shooting star", + unicode_version: "6.0", + }, + { + emoji: "🌌", + aliases: ["milky_way"], + tags: [], + category: "Travel & Places", + description: "milky way", + unicode_version: "6.0", + }, + { + emoji: "☁️", + aliases: ["cloud"], + tags: [], + category: "Travel & Places", + description: "cloud", + unicode_version: "", + }, + { + emoji: "⛅", + aliases: ["partly_sunny"], + tags: ["weather", "cloud"], + category: "Travel & Places", + description: "sun behind cloud", + unicode_version: "5.2", + }, + { + emoji: "⛈️", + aliases: ["cloud_with_lightning_and_rain"], + tags: [], + category: "Travel & Places", + description: "cloud with lightning and rain", + unicode_version: "5.2", + }, + { + emoji: "🌤️", + aliases: ["sun_behind_small_cloud"], + tags: [], + category: "Travel & Places", + description: "sun behind small cloud", + unicode_version: "7.0", + }, + { + emoji: "🌥️", + aliases: ["sun_behind_large_cloud"], + tags: [], + category: "Travel & Places", + description: "sun behind large cloud", + unicode_version: "7.0", + }, + { + emoji: "🌦️", + aliases: ["sun_behind_rain_cloud"], + tags: [], + category: "Travel & Places", + description: "sun behind rain cloud", + unicode_version: "7.0", + }, + { + emoji: "🌧️", + aliases: ["cloud_with_rain"], + tags: [], + category: "Travel & Places", + description: "cloud with rain", + unicode_version: "7.0", + }, + { + emoji: "🌨️", + aliases: ["cloud_with_snow"], + tags: [], + category: "Travel & Places", + description: "cloud with snow", + unicode_version: "7.0", + }, + { + emoji: "🌩️", + aliases: ["cloud_with_lightning"], + tags: [], + category: "Travel & Places", + description: "cloud with lightning", + unicode_version: "7.0", + }, + { + emoji: "🌪️", + aliases: ["tornado"], + tags: [], + category: "Travel & Places", + description: "tornado", + unicode_version: "7.0", + }, + { + emoji: "🌫️", + aliases: ["fog"], + tags: [], + category: "Travel & Places", + description: "fog", + unicode_version: "7.0", + }, + { + emoji: "🌬️", + aliases: ["wind_face"], + tags: [], + category: "Travel & Places", + description: "wind face", + unicode_version: "7.0", + }, + { + emoji: "🌀", + aliases: ["cyclone"], + tags: ["swirl"], + category: "Travel & Places", + description: "cyclone", + unicode_version: "6.0", + }, + { + emoji: "🌈", + aliases: ["rainbow"], + tags: [], + category: "Travel & Places", + description: "rainbow", + unicode_version: "6.0", + }, + { + emoji: "🌂", + aliases: ["closed_umbrella"], + tags: ["weather", "rain"], + category: "Travel & Places", + description: "closed umbrella", + unicode_version: "6.0", + }, + { + emoji: "☂️", + aliases: ["open_umbrella"], + tags: [], + category: "Travel & Places", + description: "umbrella", + unicode_version: "", + }, + { + emoji: "☔", + aliases: ["umbrella"], + tags: ["rain", "weather"], + category: "Travel & Places", + description: "umbrella with rain drops", + unicode_version: "4.0", + }, + { + emoji: "⛱️", + aliases: ["parasol_on_ground"], + tags: ["beach_umbrella"], + category: "Travel & Places", + description: "umbrella on ground", + unicode_version: "5.2", + }, + { + emoji: "⚡", + aliases: ["zap"], + tags: ["lightning", "thunder"], + category: "Travel & Places", + description: "high voltage", + unicode_version: "4.0", + }, + { + emoji: "❄️", + aliases: ["snowflake"], + tags: ["winter", "cold", "weather"], + category: "Travel & Places", + description: "snowflake", + unicode_version: "", + }, + { + emoji: "☃️", + aliases: ["snowman_with_snow"], + tags: ["winter", "christmas"], + category: "Travel & Places", + description: "snowman", + unicode_version: "", + }, + { + emoji: "⛄", + aliases: ["snowman"], + tags: ["winter"], + category: "Travel & Places", + description: "snowman without snow", + unicode_version: "5.2", + }, + { + emoji: "☄️", + aliases: ["comet"], + tags: [], + category: "Travel & Places", + description: "comet", + unicode_version: "", + }, + { + emoji: "🔥", + aliases: ["fire"], + tags: ["burn"], + category: "Travel & Places", + description: "fire", + unicode_version: "6.0", + }, + { + emoji: "💧", + aliases: ["droplet"], + tags: ["water"], + category: "Travel & Places", + description: "droplet", + unicode_version: "6.0", + }, + { + emoji: "🌊", + aliases: ["ocean"], + tags: ["sea"], + category: "Travel & Places", + description: "water wave", + unicode_version: "6.0", + }, + { + emoji: "🎃", + aliases: ["jack_o_lantern"], + tags: ["halloween"], + category: "Activities", + description: "jack-o-lantern", + unicode_version: "6.0", + }, + { + emoji: "🎄", + aliases: ["christmas_tree"], + tags: [], + category: "Activities", + description: "Christmas tree", + unicode_version: "6.0", + }, + { + emoji: "🎆", + aliases: ["fireworks"], + tags: ["festival", "celebration"], + category: "Activities", + description: "fireworks", + unicode_version: "6.0", + }, + { + emoji: "🎇", + aliases: ["sparkler"], + tags: [], + category: "Activities", + description: "sparkler", + unicode_version: "6.0", + }, + { + emoji: "🧨", + aliases: ["firecracker"], + tags: [], + category: "Activities", + description: "firecracker", + unicode_version: "11.0", + }, + { + emoji: "✨", + aliases: ["sparkles"], + tags: ["shiny"], + category: "Activities", + description: "sparkles", + unicode_version: "6.0", + }, + { + emoji: "🎈", + aliases: ["balloon"], + tags: ["party", "birthday"], + category: "Activities", + description: "balloon", + unicode_version: "6.0", + }, + { + emoji: "🎉", + aliases: ["tada"], + tags: ["hooray", "party"], + category: "Activities", + description: "party popper", + unicode_version: "6.0", + }, + { + emoji: "🎊", + aliases: ["confetti_ball"], + tags: [], + category: "Activities", + description: "confetti ball", + unicode_version: "6.0", + }, + { + emoji: "🎋", + aliases: ["tanabata_tree"], + tags: [], + category: "Activities", + description: "tanabata tree", + unicode_version: "6.0", + }, + { + emoji: "🎍", + aliases: ["bamboo"], + tags: [], + category: "Activities", + description: "pine decoration", + unicode_version: "6.0", + }, + { + emoji: "🎎", + aliases: ["dolls"], + tags: [], + category: "Activities", + description: "Japanese dolls", + unicode_version: "6.0", + }, + { + emoji: "🎏", + aliases: ["flags"], + tags: [], + category: "Activities", + description: "carp streamer", + unicode_version: "6.0", + }, + { + emoji: "🎐", + aliases: ["wind_chime"], + tags: [], + category: "Activities", + description: "wind chime", + unicode_version: "6.0", + }, + { + emoji: "🎑", + aliases: ["rice_scene"], + tags: [], + category: "Activities", + description: "moon viewing ceremony", + unicode_version: "6.0", + }, + { + emoji: "🧧", + aliases: ["red_envelope"], + tags: [], + category: "Activities", + description: "red envelope", + unicode_version: "11.0", + }, + { + emoji: "🎀", + aliases: ["ribbon"], + tags: [], + category: "Activities", + description: "ribbon", + unicode_version: "6.0", + }, + { + emoji: "🎁", + aliases: ["gift"], + tags: ["present", "birthday", "christmas"], + category: "Activities", + description: "wrapped gift", + unicode_version: "6.0", + }, + { + emoji: "🎗️", + aliases: ["reminder_ribbon"], + tags: [], + category: "Activities", + description: "reminder ribbon", + unicode_version: "7.0", + }, + { + emoji: "🎟️", + aliases: ["tickets"], + tags: [], + category: "Activities", + description: "admission tickets", + unicode_version: "7.0", + }, + { + emoji: "🎫", + aliases: ["ticket"], + tags: [], + category: "Activities", + description: "ticket", + unicode_version: "6.0", + }, + { + emoji: "🎖️", + aliases: ["medal_military"], + tags: [], + category: "Activities", + description: "military medal", + unicode_version: "7.0", + }, + { + emoji: "🏆", + aliases: ["trophy"], + tags: ["award", "contest", "winner"], + category: "Activities", + description: "trophy", + unicode_version: "6.0", + }, + { + emoji: "🏅", + aliases: ["medal_sports"], + tags: ["gold", "winner"], + category: "Activities", + description: "sports medal", + unicode_version: "7.0", + }, + { + emoji: "🥇", + aliases: ["1st_place_medal"], + tags: ["gold"], + category: "Activities", + description: "1st place medal", + unicode_version: "9.0", + }, + { + emoji: "🥈", + aliases: ["2nd_place_medal"], + tags: ["silver"], + category: "Activities", + description: "2nd place medal", + unicode_version: "9.0", + }, + { + emoji: "🥉", + aliases: ["3rd_place_medal"], + tags: ["bronze"], + category: "Activities", + description: "3rd place medal", + unicode_version: "9.0", + }, + { + emoji: "⚽", + aliases: ["soccer"], + tags: ["sports"], + category: "Activities", + description: "soccer ball", + unicode_version: "5.2", + }, + { + emoji: "⚾", + aliases: ["baseball"], + tags: ["sports"], + category: "Activities", + description: "baseball", + unicode_version: "5.2", + }, + { + emoji: "🥎", + aliases: ["softball"], + tags: [], + category: "Activities", + description: "softball", + unicode_version: "11.0", + }, + { + emoji: "🏀", + aliases: ["basketball"], + tags: ["sports"], + category: "Activities", + description: "basketball", + unicode_version: "6.0", + }, + { + emoji: "🏐", + aliases: ["volleyball"], + tags: [], + category: "Activities", + description: "volleyball", + unicode_version: "8.0", + }, + { + emoji: "🏈", + aliases: ["football"], + tags: ["sports"], + category: "Activities", + description: "american football", + unicode_version: "6.0", + }, + { + emoji: "🏉", + aliases: ["rugby_football"], + tags: [], + category: "Activities", + description: "rugby football", + unicode_version: "6.0", + }, + { + emoji: "🎾", + aliases: ["tennis"], + tags: ["sports"], + category: "Activities", + description: "tennis", + unicode_version: "6.0", + }, + { + emoji: "🥏", + aliases: ["flying_disc"], + tags: [], + category: "Activities", + description: "flying disc", + unicode_version: "11.0", + }, + { + emoji: "🎳", + aliases: ["bowling"], + tags: [], + category: "Activities", + description: "bowling", + unicode_version: "6.0", + }, + { + emoji: "🏏", + aliases: ["cricket_game"], + tags: [], + category: "Activities", + description: "cricket game", + unicode_version: "8.0", + }, + { + emoji: "🏑", + aliases: ["field_hockey"], + tags: [], + category: "Activities", + description: "field hockey", + unicode_version: "8.0", + }, + { + emoji: "🏒", + aliases: ["ice_hockey"], + tags: [], + category: "Activities", + description: "ice hockey", + unicode_version: "8.0", + }, + { + emoji: "🥍", + aliases: ["lacrosse"], + tags: [], + category: "Activities", + description: "lacrosse", + unicode_version: "11.0", + }, + { + emoji: "🏓", + aliases: ["ping_pong"], + tags: [], + category: "Activities", + description: "ping pong", + unicode_version: "8.0", + }, + { + emoji: "🏸", + aliases: ["badminton"], + tags: [], + category: "Activities", + description: "badminton", + unicode_version: "8.0", + }, + { + emoji: "🥊", + aliases: ["boxing_glove"], + tags: [], + category: "Activities", + description: "boxing glove", + unicode_version: "9.0", + }, + { + emoji: "🥋", + aliases: ["martial_arts_uniform"], + tags: [], + category: "Activities", + description: "martial arts uniform", + unicode_version: "9.0", + }, + { + emoji: "🥅", + aliases: ["goal_net"], + tags: [], + category: "Activities", + description: "goal net", + unicode_version: "9.0", + }, + { + emoji: "⛳", + aliases: ["golf"], + tags: [], + category: "Activities", + description: "flag in hole", + unicode_version: "5.2", + }, + { + emoji: "⛸️", + aliases: ["ice_skate"], + tags: ["skating"], + category: "Activities", + description: "ice skate", + unicode_version: "5.2", + }, + { + emoji: "🎣", + aliases: ["fishing_pole_and_fish"], + tags: [], + category: "Activities", + description: "fishing pole", + unicode_version: "6.0", + }, + { + emoji: "🤿", + aliases: ["diving_mask"], + tags: [], + category: "Activities", + description: "diving mask", + unicode_version: "12.0", + }, + { + emoji: "🎽", + aliases: ["running_shirt_with_sash"], + tags: ["marathon"], + category: "Activities", + description: "running shirt", + unicode_version: "6.0", + }, + { + emoji: "🎿", + aliases: ["ski"], + tags: [], + category: "Activities", + description: "skis", + unicode_version: "6.0", + }, + { + emoji: "🛷", + aliases: ["sled"], + tags: [], + category: "Activities", + description: "sled", + unicode_version: "11.0", + }, + { + emoji: "🥌", + aliases: ["curling_stone"], + tags: [], + category: "Activities", + description: "curling stone", + unicode_version: "11.0", + }, + { + emoji: "🎯", + aliases: ["dart"], + tags: ["target"], + category: "Activities", + description: "bullseye", + unicode_version: "6.0", + }, + { + emoji: "🪀", + aliases: ["yo_yo"], + tags: [], + category: "Activities", + description: "yo-yo", + unicode_version: "12.0", + }, + { + emoji: "🪁", + aliases: ["kite"], + tags: [], + category: "Activities", + description: "kite", + unicode_version: "12.0", + }, + { + emoji: "🎱", + aliases: ["8ball"], + tags: ["pool", "billiards"], + category: "Activities", + description: "pool 8 ball", + unicode_version: "6.0", + }, + { + emoji: "🔮", + aliases: ["crystal_ball"], + tags: ["fortune"], + category: "Activities", + description: "crystal ball", + unicode_version: "6.0", + }, + { + emoji: "🪄", + aliases: ["magic_wand"], + tags: [], + category: "Activities", + description: "magic wand", + unicode_version: "13.0", + }, + { + emoji: "🧿", + aliases: ["nazar_amulet"], + tags: [], + category: "Activities", + description: "nazar amulet", + unicode_version: "11.0", + }, + { + emoji: "🎮", + aliases: ["video_game"], + tags: ["play", "controller", "console"], + category: "Activities", + description: "video game", + unicode_version: "6.0", + }, + { + emoji: "🕹️", + aliases: ["joystick"], + tags: [], + category: "Activities", + description: "joystick", + unicode_version: "7.0", + }, + { + emoji: "🎰", + aliases: ["slot_machine"], + tags: [], + category: "Activities", + description: "slot machine", + unicode_version: "6.0", + }, + { + emoji: "🎲", + aliases: ["game_die"], + tags: ["dice", "gambling"], + category: "Activities", + description: "game die", + unicode_version: "6.0", + }, + { + emoji: "🧩", + aliases: ["jigsaw"], + tags: [], + category: "Activities", + description: "puzzle piece", + unicode_version: "11.0", + }, + { + emoji: "🧸", + aliases: ["teddy_bear"], + tags: [], + category: "Activities", + description: "teddy bear", + unicode_version: "11.0", + }, + { + emoji: "🪅", + aliases: ["pinata"], + tags: [], + category: "Activities", + description: "piñata", + unicode_version: "13.0", + }, + { + emoji: "🪆", + aliases: ["nesting_dolls"], + tags: [], + category: "Activities", + description: "nesting dolls", + unicode_version: "13.0", + }, + { + emoji: "♠️", + aliases: ["spades"], + tags: [], + category: "Activities", + description: "spade suit", + unicode_version: "", + }, + { + emoji: "♥️", + aliases: ["hearts"], + tags: [], + category: "Activities", + description: "heart suit", + unicode_version: "", + }, + { + emoji: "♦️", + aliases: ["diamonds"], + tags: [], + category: "Activities", + description: "diamond suit", + unicode_version: "", + }, + { + emoji: "♣️", + aliases: ["clubs"], + tags: [], + category: "Activities", + description: "club suit", + unicode_version: "", + }, + { + emoji: "♟️", + aliases: ["chess_pawn"], + tags: [], + category: "Activities", + description: "chess pawn", + unicode_version: "11.0", + }, + { + emoji: "🃏", + aliases: ["black_joker"], + tags: [], + category: "Activities", + description: "joker", + unicode_version: "6.0", + }, + { + emoji: "🀄", + aliases: ["mahjong"], + tags: [], + category: "Activities", + description: "mahjong red dragon", + unicode_version: "", + }, + { + emoji: "🎴", + aliases: ["flower_playing_cards"], + tags: [], + category: "Activities", + description: "flower playing cards", + unicode_version: "6.0", + }, + { + emoji: "🎭", + aliases: ["performing_arts"], + tags: ["theater", "drama"], + category: "Activities", + description: "performing arts", + unicode_version: "6.0", + }, + { + emoji: "🖼️", + aliases: ["framed_picture"], + tags: [], + category: "Activities", + description: "framed picture", + unicode_version: "7.0", + }, + { + emoji: "🎨", + aliases: ["art"], + tags: ["design", "paint"], + category: "Activities", + description: "artist palette", + unicode_version: "6.0", + }, + { + emoji: "🧵", + aliases: ["thread"], + tags: [], + category: "Activities", + description: "thread", + unicode_version: "11.0", + }, + { + emoji: "🪡", + aliases: ["sewing_needle"], + tags: [], + category: "Activities", + description: "sewing needle", + unicode_version: "13.0", + }, + { + emoji: "🧶", + aliases: ["yarn"], + tags: [], + category: "Activities", + description: "yarn", + unicode_version: "11.0", + }, + { + emoji: "🪢", + aliases: ["knot"], + tags: [], + category: "Activities", + description: "knot", + unicode_version: "13.0", + }, + { + emoji: "👓", + aliases: ["eyeglasses"], + tags: ["glasses"], + category: "Objects", + description: "glasses", + unicode_version: "6.0", + }, + { + emoji: "🕶️", + aliases: ["dark_sunglasses"], + tags: [], + category: "Objects", + description: "sunglasses", + unicode_version: "7.0", + }, + { + emoji: "🥽", + aliases: ["goggles"], + tags: [], + category: "Objects", + description: "goggles", + unicode_version: "11.0", + }, + { + emoji: "🥼", + aliases: ["lab_coat"], + tags: [], + category: "Objects", + description: "lab coat", + unicode_version: "11.0", + }, + { + emoji: "🦺", + aliases: ["safety_vest"], + tags: [], + category: "Objects", + description: "safety vest", + unicode_version: "12.0", + }, + { + emoji: "👔", + aliases: ["necktie"], + tags: ["shirt", "formal"], + category: "Objects", + description: "necktie", + unicode_version: "6.0", + }, + { + emoji: "👕", + aliases: ["shirt", "tshirt"], + tags: [], + category: "Objects", + description: "t-shirt", + unicode_version: "6.0", + }, + { + emoji: "👖", + aliases: ["jeans"], + tags: ["pants"], + category: "Objects", + description: "jeans", + unicode_version: "6.0", + }, + { + emoji: "🧣", + aliases: ["scarf"], + tags: [], + category: "Objects", + description: "scarf", + unicode_version: "11.0", + }, + { + emoji: "🧤", + aliases: ["gloves"], + tags: [], + category: "Objects", + description: "gloves", + unicode_version: "11.0", + }, + { + emoji: "🧥", + aliases: ["coat"], + tags: [], + category: "Objects", + description: "coat", + unicode_version: "11.0", + }, + { + emoji: "🧦", + aliases: ["socks"], + tags: [], + category: "Objects", + description: "socks", + unicode_version: "11.0", + }, + { + emoji: "👗", + aliases: ["dress"], + tags: [], + category: "Objects", + description: "dress", + unicode_version: "6.0", + }, + { + emoji: "👘", + aliases: ["kimono"], + tags: [], + category: "Objects", + description: "kimono", + unicode_version: "6.0", + }, + { + emoji: "🥻", + aliases: ["sari"], + tags: [], + category: "Objects", + description: "sari", + unicode_version: "12.0", + }, + { + emoji: "🩱", + aliases: ["one_piece_swimsuit"], + tags: [], + category: "Objects", + description: "one-piece swimsuit", + unicode_version: "12.0", + }, + { + emoji: "🩲", + aliases: ["swim_brief"], + tags: [], + category: "Objects", + description: "briefs", + unicode_version: "12.0", + }, + { + emoji: "🩳", + aliases: ["shorts"], + tags: [], + category: "Objects", + description: "shorts", + unicode_version: "12.0", + }, + { + emoji: "👙", + aliases: ["bikini"], + tags: ["beach"], + category: "Objects", + description: "bikini", + unicode_version: "6.0", + }, + { + emoji: "👚", + aliases: ["womans_clothes"], + tags: [], + category: "Objects", + description: "woman’s clothes", + unicode_version: "6.0", + }, + { + emoji: "👛", + aliases: ["purse"], + tags: [], + category: "Objects", + description: "purse", + unicode_version: "6.0", + }, + { + emoji: "👜", + aliases: ["handbag"], + tags: ["bag"], + category: "Objects", + description: "handbag", + unicode_version: "6.0", + }, + { + emoji: "👝", + aliases: ["pouch"], + tags: ["bag"], + category: "Objects", + description: "clutch bag", + unicode_version: "6.0", + }, + { + emoji: "🛍️", + aliases: ["shopping"], + tags: ["bags"], + category: "Objects", + description: "shopping bags", + unicode_version: "7.0", + }, + { + emoji: "🎒", + aliases: ["school_satchel"], + tags: [], + category: "Objects", + description: "backpack", + unicode_version: "6.0", + }, + { + emoji: "🩴", + aliases: ["thong_sandal"], + tags: [], + category: "Objects", + description: "thong sandal", + unicode_version: "13.0", + }, + { + emoji: "👞", + aliases: ["mans_shoe", "shoe"], + tags: [], + category: "Objects", + description: "man’s shoe", + unicode_version: "6.0", + }, + { + emoji: "👟", + aliases: ["athletic_shoe"], + tags: ["sneaker", "sport", "running"], + category: "Objects", + description: "running shoe", + unicode_version: "6.0", + }, + { + emoji: "🥾", + aliases: ["hiking_boot"], + tags: [], + category: "Objects", + description: "hiking boot", + unicode_version: "11.0", + }, + { + emoji: "🥿", + aliases: ["flat_shoe"], + tags: [], + category: "Objects", + description: "flat shoe", + unicode_version: "11.0", + }, + { + emoji: "👠", + aliases: ["high_heel"], + tags: ["shoe"], + category: "Objects", + description: "high-heeled shoe", + unicode_version: "6.0", + }, + { + emoji: "👡", + aliases: ["sandal"], + tags: ["shoe"], + category: "Objects", + description: "woman’s sandal", + unicode_version: "6.0", + }, + { + emoji: "🩰", + aliases: ["ballet_shoes"], + tags: [], + category: "Objects", + description: "ballet shoes", + unicode_version: "12.0", + }, + { + emoji: "👢", + aliases: ["boot"], + tags: [], + category: "Objects", + description: "woman’s boot", + unicode_version: "6.0", + }, + { + emoji: "👑", + aliases: ["crown"], + tags: ["king", "queen", "royal"], + category: "Objects", + description: "crown", + unicode_version: "6.0", + }, + { + emoji: "👒", + aliases: ["womans_hat"], + tags: [], + category: "Objects", + description: "woman’s hat", + unicode_version: "6.0", + }, + { + emoji: "🎩", + aliases: ["tophat"], + tags: ["hat", "classy"], + category: "Objects", + description: "top hat", + unicode_version: "6.0", + }, + { + emoji: "🎓", + aliases: ["mortar_board"], + tags: ["education", "college", "university", "graduation"], + category: "Objects", + description: "graduation cap", + unicode_version: "6.0", + }, + { + emoji: "🧢", + aliases: ["billed_cap"], + tags: [], + category: "Objects", + description: "billed cap", + unicode_version: "11.0", + }, + { + emoji: "🪖", + aliases: ["military_helmet"], + tags: [], + category: "Objects", + description: "military helmet", + unicode_version: "13.0", + }, + { + emoji: "⛑️", + aliases: ["rescue_worker_helmet"], + tags: [], + category: "Objects", + description: "rescue worker’s helmet", + unicode_version: "5.2", + }, + { + emoji: "📿", + aliases: ["prayer_beads"], + tags: [], + category: "Objects", + description: "prayer beads", + unicode_version: "8.0", + }, + { + emoji: "💄", + aliases: ["lipstick"], + tags: ["makeup"], + category: "Objects", + description: "lipstick", + unicode_version: "6.0", + }, + { + emoji: "💍", + aliases: ["ring"], + tags: ["wedding", "marriage", "engaged"], + category: "Objects", + description: "ring", + unicode_version: "6.0", + }, + { + emoji: "💎", + aliases: ["gem"], + tags: ["diamond"], + category: "Objects", + description: "gem stone", + unicode_version: "6.0", + }, + { + emoji: "🔇", + aliases: ["mute"], + tags: ["sound", "volume"], + category: "Objects", + description: "muted speaker", + unicode_version: "6.0", + }, + { + emoji: "🔈", + aliases: ["speaker"], + tags: [], + category: "Objects", + description: "speaker low volume", + unicode_version: "6.0", + }, + { + emoji: "🔉", + aliases: ["sound"], + tags: ["volume"], + category: "Objects", + description: "speaker medium volume", + unicode_version: "6.0", + }, + { + emoji: "🔊", + aliases: ["loud_sound"], + tags: ["volume"], + category: "Objects", + description: "speaker high volume", + unicode_version: "6.0", + }, + { + emoji: "📢", + aliases: ["loudspeaker"], + tags: ["announcement"], + category: "Objects", + description: "loudspeaker", + unicode_version: "6.0", + }, + { + emoji: "📣", + aliases: ["mega"], + tags: [], + category: "Objects", + description: "megaphone", + unicode_version: "6.0", + }, + { + emoji: "📯", + aliases: ["postal_horn"], + tags: [], + category: "Objects", + description: "postal horn", + unicode_version: "6.0", + }, + { + emoji: "🔔", + aliases: ["bell"], + tags: ["sound", "notification"], + category: "Objects", + description: "bell", + unicode_version: "6.0", + }, + { + emoji: "🔕", + aliases: ["no_bell"], + tags: ["volume", "off"], + category: "Objects", + description: "bell with slash", + unicode_version: "6.0", + }, + { + emoji: "🎼", + aliases: ["musical_score"], + tags: [], + category: "Objects", + description: "musical score", + unicode_version: "6.0", + }, + { + emoji: "🎵", + aliases: ["musical_note"], + tags: [], + category: "Objects", + description: "musical note", + unicode_version: "6.0", + }, + { + emoji: "🎶", + aliases: ["notes"], + tags: ["music"], + category: "Objects", + description: "musical notes", + unicode_version: "6.0", + }, + { + emoji: "🎙️", + aliases: ["studio_microphone"], + tags: ["podcast"], + category: "Objects", + description: "studio microphone", + unicode_version: "7.0", + }, + { + emoji: "🎚️", + aliases: ["level_slider"], + tags: [], + category: "Objects", + description: "level slider", + unicode_version: "7.0", + }, + { + emoji: "🎛️", + aliases: ["control_knobs"], + tags: [], + category: "Objects", + description: "control knobs", + unicode_version: "7.0", + }, + { + emoji: "🎤", + aliases: ["microphone"], + tags: ["sing"], + category: "Objects", + description: "microphone", + unicode_version: "6.0", + }, + { + emoji: "🎧", + aliases: ["headphones"], + tags: ["music", "earphones"], + category: "Objects", + description: "headphone", + unicode_version: "6.0", + }, + { + emoji: "📻", + aliases: ["radio"], + tags: ["podcast"], + category: "Objects", + description: "radio", + unicode_version: "6.0", + }, + { + emoji: "🎷", + aliases: ["saxophone"], + tags: [], + category: "Objects", + description: "saxophone", + unicode_version: "6.0", + }, + { + emoji: "🪗", + aliases: ["accordion"], + tags: [], + category: "Objects", + description: "accordion", + unicode_version: "13.0", + }, + { + emoji: "🎸", + aliases: ["guitar"], + tags: ["rock"], + category: "Objects", + description: "guitar", + unicode_version: "6.0", + }, + { + emoji: "🎹", + aliases: ["musical_keyboard"], + tags: ["piano"], + category: "Objects", + description: "musical keyboard", + unicode_version: "6.0", + }, + { + emoji: "🎺", + aliases: ["trumpet"], + tags: [], + category: "Objects", + description: "trumpet", + unicode_version: "6.0", + }, + { + emoji: "🎻", + aliases: ["violin"], + tags: [], + category: "Objects", + description: "violin", + unicode_version: "6.0", + }, + { + emoji: "🪕", + aliases: ["banjo"], + tags: [], + category: "Objects", + description: "banjo", + unicode_version: "12.0", + }, + { + emoji: "🥁", + aliases: ["drum"], + tags: [], + category: "Objects", + description: "drum", + unicode_version: "", + }, + { + emoji: "🪘", + aliases: ["long_drum"], + tags: [], + category: "Objects", + description: "long drum", + unicode_version: "13.0", + }, + { + emoji: "📱", + aliases: ["iphone"], + tags: ["smartphone", "mobile"], + category: "Objects", + description: "mobile phone", + unicode_version: "6.0", + }, + { + emoji: "📲", + aliases: ["calling"], + tags: ["call", "incoming"], + category: "Objects", + description: "mobile phone with arrow", + unicode_version: "6.0", + }, + { + emoji: "☎️", + aliases: ["phone", "telephone"], + tags: [], + category: "Objects", + description: "telephone", + unicode_version: "", + }, + { + emoji: "📞", + aliases: ["telephone_receiver"], + tags: ["phone", "call"], + category: "Objects", + description: "telephone receiver", + unicode_version: "6.0", + }, + { + emoji: "📟", + aliases: ["pager"], + tags: [], + category: "Objects", + description: "pager", + unicode_version: "6.0", + }, + { + emoji: "📠", + aliases: ["fax"], + tags: [], + category: "Objects", + description: "fax machine", + unicode_version: "6.0", + }, + { + emoji: "🔋", + aliases: ["battery"], + tags: ["power"], + category: "Objects", + description: "battery", + unicode_version: "6.0", + }, + { + emoji: "🔌", + aliases: ["electric_plug"], + tags: [], + category: "Objects", + description: "electric plug", + unicode_version: "6.0", + }, + { + emoji: "💻", + aliases: ["computer"], + tags: ["desktop", "screen"], + category: "Objects", + description: "laptop", + unicode_version: "6.0", + }, + { + emoji: "🖥️", + aliases: ["desktop_computer"], + tags: [], + category: "Objects", + description: "desktop computer", + unicode_version: "7.0", + }, + { + emoji: "🖨️", + aliases: ["printer"], + tags: [], + category: "Objects", + description: "printer", + unicode_version: "7.0", + }, + { + emoji: "⌨️", + aliases: ["keyboard"], + tags: [], + category: "Objects", + description: "keyboard", + unicode_version: "", + }, + { + emoji: "🖱️", + aliases: ["computer_mouse"], + tags: [], + category: "Objects", + description: "computer mouse", + unicode_version: "7.0", + }, + { + emoji: "🖲️", + aliases: ["trackball"], + tags: [], + category: "Objects", + description: "trackball", + unicode_version: "7.0", + }, + { + emoji: "💽", + aliases: ["minidisc"], + tags: [], + category: "Objects", + description: "computer disk", + unicode_version: "6.0", + }, + { + emoji: "💾", + aliases: ["floppy_disk"], + tags: ["save"], + category: "Objects", + description: "floppy disk", + unicode_version: "6.0", + }, + { + emoji: "💿", + aliases: ["cd"], + tags: [], + category: "Objects", + description: "optical disk", + unicode_version: "6.0", + }, + { + emoji: "📀", + aliases: ["dvd"], + tags: [], + category: "Objects", + description: "dvd", + unicode_version: "6.0", + }, + { + emoji: "🧮", + aliases: ["abacus"], + tags: [], + category: "Objects", + description: "abacus", + unicode_version: "11.0", + }, + { + emoji: "🎥", + aliases: ["movie_camera"], + tags: ["film", "video"], + category: "Objects", + description: "movie camera", + unicode_version: "6.0", + }, + { + emoji: "🎞️", + aliases: ["film_strip"], + tags: [], + category: "Objects", + description: "film frames", + unicode_version: "7.0", + }, + { + emoji: "📽️", + aliases: ["film_projector"], + tags: [], + category: "Objects", + description: "film projector", + unicode_version: "7.0", + }, + { + emoji: "🎬", + aliases: ["clapper"], + tags: ["film"], + category: "Objects", + description: "clapper board", + unicode_version: "6.0", + }, + { + emoji: "📺", + aliases: ["tv"], + tags: [], + category: "Objects", + description: "television", + unicode_version: "6.0", + }, + { + emoji: "📷", + aliases: ["camera"], + tags: ["photo"], + category: "Objects", + description: "camera", + unicode_version: "6.0", + }, + { + emoji: "📸", + aliases: ["camera_flash"], + tags: ["photo"], + category: "Objects", + description: "camera with flash", + unicode_version: "7.0", + }, + { + emoji: "📹", + aliases: ["video_camera"], + tags: [], + category: "Objects", + description: "video camera", + unicode_version: "6.0", + }, + { + emoji: "📼", + aliases: ["vhs"], + tags: [], + category: "Objects", + description: "videocassette", + unicode_version: "6.0", + }, + { + emoji: "🔍", + aliases: ["mag"], + tags: ["search", "zoom"], + category: "Objects", + description: "magnifying glass tilted left", + unicode_version: "6.0", + }, + { + emoji: "🔎", + aliases: ["mag_right"], + tags: [], + category: "Objects", + description: "magnifying glass tilted right", + unicode_version: "6.0", + }, + { + emoji: "🕯️", + aliases: ["candle"], + tags: [], + category: "Objects", + description: "candle", + unicode_version: "7.0", + }, + { + emoji: "💡", + aliases: ["bulb"], + tags: ["idea", "light"], + category: "Objects", + description: "light bulb", + unicode_version: "6.0", + }, + { + emoji: "🔦", + aliases: ["flashlight"], + tags: [], + category: "Objects", + description: "flashlight", + unicode_version: "6.0", + }, + { + emoji: "🏮", + aliases: ["izakaya_lantern", "lantern"], + tags: [], + category: "Objects", + description: "red paper lantern", + unicode_version: "6.0", + }, + { + emoji: "🪔", + aliases: ["diya_lamp"], + tags: [], + category: "Objects", + description: "diya lamp", + unicode_version: "12.0", + }, + { + emoji: "📔", + aliases: ["notebook_with_decorative_cover"], + tags: [], + category: "Objects", + description: "notebook with decorative cover", + unicode_version: "6.0", + }, + { + emoji: "📕", + aliases: ["closed_book"], + tags: [], + category: "Objects", + description: "closed book", + unicode_version: "6.0", + }, + { + emoji: "📖", + aliases: ["book", "open_book"], + tags: [], + category: "Objects", + description: "open book", + unicode_version: "6.0", + }, + { + emoji: "📗", + aliases: ["green_book"], + tags: [], + category: "Objects", + description: "green book", + unicode_version: "6.0", + }, + { + emoji: "📘", + aliases: ["blue_book"], + tags: [], + category: "Objects", + description: "blue book", + unicode_version: "6.0", + }, + { + emoji: "📙", + aliases: ["orange_book"], + tags: [], + category: "Objects", + description: "orange book", + unicode_version: "6.0", + }, + { + emoji: "📚", + aliases: ["books"], + tags: ["library"], + category: "Objects", + description: "books", + unicode_version: "6.0", + }, + { + emoji: "📓", + aliases: ["notebook"], + tags: [], + category: "Objects", + description: "notebook", + unicode_version: "6.0", + }, + { + emoji: "📒", + aliases: ["ledger"], + tags: [], + category: "Objects", + description: "ledger", + unicode_version: "6.0", + }, + { + emoji: "📃", + aliases: ["page_with_curl"], + tags: [], + category: "Objects", + description: "page with curl", + unicode_version: "6.0", + }, + { + emoji: "📜", + aliases: ["scroll"], + tags: ["document"], + category: "Objects", + description: "scroll", + unicode_version: "6.0", + }, + { + emoji: "📄", + aliases: ["page_facing_up"], + tags: ["document"], + category: "Objects", + description: "page facing up", + unicode_version: "6.0", + }, + { + emoji: "📰", + aliases: ["newspaper"], + tags: ["press"], + category: "Objects", + description: "newspaper", + unicode_version: "6.0", + }, + { + emoji: "🗞️", + aliases: ["newspaper_roll"], + tags: ["press"], + category: "Objects", + description: "rolled-up newspaper", + unicode_version: "7.0", + }, + { + emoji: "📑", + aliases: ["bookmark_tabs"], + tags: [], + category: "Objects", + description: "bookmark tabs", + unicode_version: "6.0", + }, + { + emoji: "🔖", + aliases: ["bookmark"], + tags: [], + category: "Objects", + description: "bookmark", + unicode_version: "6.0", + }, + { + emoji: "🏷️", + aliases: ["label"], + tags: ["tag"], + category: "Objects", + description: "label", + unicode_version: "7.0", + }, + { + emoji: "💰", + aliases: ["moneybag"], + tags: ["dollar", "cream"], + category: "Objects", + description: "money bag", + unicode_version: "6.0", + }, + { + emoji: "🪙", + aliases: ["coin"], + tags: [], + category: "Objects", + description: "coin", + unicode_version: "13.0", + }, + { + emoji: "💴", + aliases: ["yen"], + tags: [], + category: "Objects", + description: "yen banknote", + unicode_version: "6.0", + }, + { + emoji: "💵", + aliases: ["dollar"], + tags: ["money"], + category: "Objects", + description: "dollar banknote", + unicode_version: "6.0", + }, + { + emoji: "💶", + aliases: ["euro"], + tags: [], + category: "Objects", + description: "euro banknote", + unicode_version: "6.0", + }, + { + emoji: "💷", + aliases: ["pound"], + tags: [], + category: "Objects", + description: "pound banknote", + unicode_version: "6.0", + }, + { + emoji: "💸", + aliases: ["money_with_wings"], + tags: ["dollar"], + category: "Objects", + description: "money with wings", + unicode_version: "6.0", + }, + { + emoji: "💳", + aliases: ["credit_card"], + tags: ["subscription"], + category: "Objects", + description: "credit card", + unicode_version: "6.0", + }, + { + emoji: "🧾", + aliases: ["receipt"], + tags: [], + category: "Objects", + description: "receipt", + unicode_version: "11.0", + }, + { + emoji: "💹", + aliases: ["chart"], + tags: [], + category: "Objects", + description: "chart increasing with yen", + unicode_version: "6.0", + }, + { + emoji: "✉️", + aliases: ["envelope"], + tags: ["letter", "email"], + category: "Objects", + description: "envelope", + unicode_version: "", + }, + { + emoji: "📧", + aliases: ["email", "e-mail"], + tags: [], + category: "Objects", + description: "e-mail", + unicode_version: "6.0", + }, + { + emoji: "📨", + aliases: ["incoming_envelope"], + tags: [], + category: "Objects", + description: "incoming envelope", + unicode_version: "6.0", + }, + { + emoji: "📩", + aliases: ["envelope_with_arrow"], + tags: [], + category: "Objects", + description: "envelope with arrow", + unicode_version: "6.0", + }, + { + emoji: "📤", + aliases: ["outbox_tray"], + tags: [], + category: "Objects", + description: "outbox tray", + unicode_version: "6.0", + }, + { + emoji: "📥", + aliases: ["inbox_tray"], + tags: [], + category: "Objects", + description: "inbox tray", + unicode_version: "6.0", + }, + { + emoji: "📦", + aliases: ["package"], + tags: ["shipping"], + category: "Objects", + description: "package", + unicode_version: "6.0", + }, + { + emoji: "📫", + aliases: ["mailbox"], + tags: [], + category: "Objects", + description: "closed mailbox with raised flag", + unicode_version: "6.0", + }, + { + emoji: "📪", + aliases: ["mailbox_closed"], + tags: [], + category: "Objects", + description: "closed mailbox with lowered flag", + unicode_version: "6.0", + }, + { + emoji: "📬", + aliases: ["mailbox_with_mail"], + tags: [], + category: "Objects", + description: "open mailbox with raised flag", + unicode_version: "6.0", + }, + { + emoji: "📭", + aliases: ["mailbox_with_no_mail"], + tags: [], + category: "Objects", + description: "open mailbox with lowered flag", + unicode_version: "6.0", + }, + { + emoji: "📮", + aliases: ["postbox"], + tags: [], + category: "Objects", + description: "postbox", + unicode_version: "6.0", + }, + { + emoji: "🗳️", + aliases: ["ballot_box"], + tags: [], + category: "Objects", + description: "ballot box with ballot", + unicode_version: "7.0", + }, + { + emoji: "✏️", + aliases: ["pencil2"], + tags: [], + category: "Objects", + description: "pencil", + unicode_version: "", + }, + { + emoji: "✒️", + aliases: ["black_nib"], + tags: [], + category: "Objects", + description: "black nib", + unicode_version: "", + }, + { + emoji: "🖋️", + aliases: ["fountain_pen"], + tags: [], + category: "Objects", + description: "fountain pen", + unicode_version: "7.0", + }, + { + emoji: "🖊️", + aliases: ["pen"], + tags: [], + category: "Objects", + description: "pen", + unicode_version: "7.0", + }, + { + emoji: "🖌️", + aliases: ["paintbrush"], + tags: [], + category: "Objects", + description: "paintbrush", + unicode_version: "7.0", + }, + { + emoji: "🖍️", + aliases: ["crayon"], + tags: [], + category: "Objects", + description: "crayon", + unicode_version: "7.0", + }, + { + emoji: "📝", + aliases: ["memo", "pencil"], + tags: ["document", "note"], + category: "Objects", + description: "memo", + unicode_version: "6.0", + }, + { + emoji: "💼", + aliases: ["briefcase"], + tags: ["business"], + category: "Objects", + description: "briefcase", + unicode_version: "6.0", + }, + { + emoji: "📁", + aliases: ["file_folder"], + tags: ["directory"], + category: "Objects", + description: "file folder", + unicode_version: "6.0", + }, + { + emoji: "📂", + aliases: ["open_file_folder"], + tags: [], + category: "Objects", + description: "open file folder", + unicode_version: "6.0", + }, + { + emoji: "🗂️", + aliases: ["card_index_dividers"], + tags: [], + category: "Objects", + description: "card index dividers", + unicode_version: "7.0", + }, + { + emoji: "📅", + aliases: ["date"], + tags: ["calendar", "schedule"], + category: "Objects", + description: "calendar", + unicode_version: "6.0", + }, + { + emoji: "📆", + aliases: ["calendar"], + tags: ["schedule"], + category: "Objects", + description: "tear-off calendar", + unicode_version: "6.0", + }, + { + emoji: "🗒️", + aliases: ["spiral_notepad"], + tags: [], + category: "Objects", + description: "spiral notepad", + unicode_version: "7.0", + }, + { + emoji: "🗓️", + aliases: ["spiral_calendar"], + tags: [], + category: "Objects", + description: "spiral calendar", + unicode_version: "7.0", + }, + { + emoji: "📇", + aliases: ["card_index"], + tags: [], + category: "Objects", + description: "card index", + unicode_version: "6.0", + }, + { + emoji: "📈", + aliases: ["chart_with_upwards_trend"], + tags: ["graph", "metrics"], + category: "Objects", + description: "chart increasing", + unicode_version: "6.0", + }, + { + emoji: "📉", + aliases: ["chart_with_downwards_trend"], + tags: ["graph", "metrics"], + category: "Objects", + description: "chart decreasing", + unicode_version: "6.0", + }, + { + emoji: "📊", + aliases: ["bar_chart"], + tags: ["stats", "metrics"], + category: "Objects", + description: "bar chart", + unicode_version: "6.0", + }, + { + emoji: "📋", + aliases: ["clipboard"], + tags: [], + category: "Objects", + description: "clipboard", + unicode_version: "6.0", + }, + { + emoji: "📌", + aliases: ["pushpin"], + tags: ["location"], + category: "Objects", + description: "pushpin", + unicode_version: "6.0", + }, + { + emoji: "📍", + aliases: ["round_pushpin"], + tags: ["location"], + category: "Objects", + description: "round pushpin", + unicode_version: "6.0", + }, + { + emoji: "📎", + aliases: ["paperclip"], + tags: [], + category: "Objects", + description: "paperclip", + unicode_version: "6.0", + }, + { + emoji: "🖇️", + aliases: ["paperclips"], + tags: [], + category: "Objects", + description: "linked paperclips", + unicode_version: "7.0", + }, + { + emoji: "📏", + aliases: ["straight_ruler"], + tags: [], + category: "Objects", + description: "straight ruler", + unicode_version: "6.0", + }, + { + emoji: "📐", + aliases: ["triangular_ruler"], + tags: [], + category: "Objects", + description: "triangular ruler", + unicode_version: "6.0", + }, + { + emoji: "✂️", + aliases: ["scissors"], + tags: ["cut"], + category: "Objects", + description: "scissors", + unicode_version: "", + }, + { + emoji: "🗃️", + aliases: ["card_file_box"], + tags: [], + category: "Objects", + description: "card file box", + unicode_version: "7.0", + }, + { + emoji: "🗄️", + aliases: ["file_cabinet"], + tags: [], + category: "Objects", + description: "file cabinet", + unicode_version: "7.0", + }, + { + emoji: "🗑️", + aliases: ["wastebasket"], + tags: ["trash"], + category: "Objects", + description: "wastebasket", + unicode_version: "7.0", + }, + { + emoji: "🔒", + aliases: ["lock"], + tags: ["security", "private"], + category: "Objects", + description: "locked", + unicode_version: "6.0", + }, + { + emoji: "🔓", + aliases: ["unlock"], + tags: ["security"], + category: "Objects", + description: "unlocked", + unicode_version: "6.0", + }, + { + emoji: "🔏", + aliases: ["lock_with_ink_pen"], + tags: [], + category: "Objects", + description: "locked with pen", + unicode_version: "6.0", + }, + { + emoji: "🔐", + aliases: ["closed_lock_with_key"], + tags: ["security"], + category: "Objects", + description: "locked with key", + unicode_version: "6.0", + }, + { + emoji: "🔑", + aliases: ["key"], + tags: ["lock", "password"], + category: "Objects", + description: "key", + unicode_version: "6.0", + }, + { + emoji: "🗝️", + aliases: ["old_key"], + tags: [], + category: "Objects", + description: "old key", + unicode_version: "7.0", + }, + { + emoji: "🔨", + aliases: ["hammer"], + tags: ["tool"], + category: "Objects", + description: "hammer", + unicode_version: "6.0", + }, + { + emoji: "🪓", + aliases: ["axe"], + tags: [], + category: "Objects", + description: "axe", + unicode_version: "12.0", + }, + { + emoji: "⛏️", + aliases: ["pick"], + tags: [], + category: "Objects", + description: "pick", + unicode_version: "5.2", + }, + { + emoji: "⚒️", + aliases: ["hammer_and_pick"], + tags: [], + category: "Objects", + description: "hammer and pick", + unicode_version: "4.1", + }, + { + emoji: "🛠️", + aliases: ["hammer_and_wrench"], + tags: [], + category: "Objects", + description: "hammer and wrench", + unicode_version: "7.0", + }, + { + emoji: "🗡️", + aliases: ["dagger"], + tags: [], + category: "Objects", + description: "dagger", + unicode_version: "7.0", + }, + { + emoji: "⚔️", + aliases: ["crossed_swords"], + tags: [], + category: "Objects", + description: "crossed swords", + unicode_version: "4.1", + }, + { + emoji: "🔫", + aliases: ["gun"], + tags: ["shoot", "weapon"], + category: "Objects", + description: "water pistol", + unicode_version: "6.0", + }, + { + emoji: "🪃", + aliases: ["boomerang"], + tags: [], + category: "Objects", + description: "boomerang", + unicode_version: "13.0", + }, + { + emoji: "🏹", + aliases: ["bow_and_arrow"], + tags: ["archery"], + category: "Objects", + description: "bow and arrow", + unicode_version: "8.0", + }, + { + emoji: "🛡️", + aliases: ["shield"], + tags: [], + category: "Objects", + description: "shield", + unicode_version: "7.0", + }, + { + emoji: "🪚", + aliases: ["carpentry_saw"], + tags: [], + category: "Objects", + description: "carpentry saw", + unicode_version: "13.0", + }, + { + emoji: "🔧", + aliases: ["wrench"], + tags: ["tool"], + category: "Objects", + description: "wrench", + unicode_version: "6.0", + }, + { + emoji: "🪛", + aliases: ["screwdriver"], + tags: [], + category: "Objects", + description: "screwdriver", + unicode_version: "13.0", + }, + { + emoji: "🔩", + aliases: ["nut_and_bolt"], + tags: [], + category: "Objects", + description: "nut and bolt", + unicode_version: "6.0", + }, + { + emoji: "⚙️", + aliases: ["gear"], + tags: [], + category: "Objects", + description: "gear", + unicode_version: "4.1", + }, + { + emoji: "🗜️", + aliases: ["clamp"], + tags: [], + category: "Objects", + description: "clamp", + unicode_version: "7.0", + }, + { + emoji: "⚖️", + aliases: ["balance_scale"], + tags: [], + category: "Objects", + description: "balance scale", + unicode_version: "4.1", + }, + { + emoji: "🦯", + aliases: ["probing_cane"], + tags: [], + category: "Objects", + description: "white cane", + unicode_version: "12.0", + }, + { + emoji: "🔗", + aliases: ["link"], + tags: [], + category: "Objects", + description: "link", + unicode_version: "6.0", + }, + { + emoji: "⛓️", + aliases: ["chains"], + tags: [], + category: "Objects", + description: "chains", + unicode_version: "5.2", + }, + { + emoji: "🪝", + aliases: ["hook"], + tags: [], + category: "Objects", + description: "hook", + unicode_version: "13.0", + }, + { + emoji: "🧰", + aliases: ["toolbox"], + tags: [], + category: "Objects", + description: "toolbox", + unicode_version: "11.0", + }, + { + emoji: "🧲", + aliases: ["magnet"], + tags: [], + category: "Objects", + description: "magnet", + unicode_version: "11.0", + }, + { + emoji: "🪜", + aliases: ["ladder"], + tags: [], + category: "Objects", + description: "ladder", + unicode_version: "13.0", + }, + { + emoji: "⚗️", + aliases: ["alembic"], + tags: [], + category: "Objects", + description: "alembic", + unicode_version: "4.1", + }, + { + emoji: "🧪", + aliases: ["test_tube"], + tags: [], + category: "Objects", + description: "test tube", + unicode_version: "11.0", + }, + { + emoji: "🧫", + aliases: ["petri_dish"], + tags: [], + category: "Objects", + description: "petri dish", + unicode_version: "11.0", + }, + { + emoji: "🧬", + aliases: ["dna"], + tags: [], + category: "Objects", + description: "dna", + unicode_version: "11.0", + }, + { + emoji: "🔬", + aliases: ["microscope"], + tags: ["science", "laboratory", "investigate"], + category: "Objects", + description: "microscope", + unicode_version: "6.0", + }, + { + emoji: "🔭", + aliases: ["telescope"], + tags: [], + category: "Objects", + description: "telescope", + unicode_version: "6.0", + }, + { + emoji: "📡", + aliases: ["satellite"], + tags: ["signal"], + category: "Objects", + description: "satellite antenna", + unicode_version: "6.0", + }, + { + emoji: "💉", + aliases: ["syringe"], + tags: ["health", "hospital", "needle"], + category: "Objects", + description: "syringe", + unicode_version: "6.0", + }, + { + emoji: "🩸", + aliases: ["drop_of_blood"], + tags: [], + category: "Objects", + description: "drop of blood", + unicode_version: "12.0", + }, + { + emoji: "💊", + aliases: ["pill"], + tags: ["health", "medicine"], + category: "Objects", + description: "pill", + unicode_version: "6.0", + }, + { + emoji: "🩹", + aliases: ["adhesive_bandage"], + tags: [], + category: "Objects", + description: "adhesive bandage", + unicode_version: "12.0", + }, + { + emoji: "🩺", + aliases: ["stethoscope"], + tags: [], + category: "Objects", + description: "stethoscope", + unicode_version: "12.0", + }, + { + emoji: "🚪", + aliases: ["door"], + tags: [], + category: "Objects", + description: "door", + unicode_version: "6.0", + }, + { + emoji: "🛗", + aliases: ["elevator"], + tags: [], + category: "Objects", + description: "elevator", + unicode_version: "13.0", + }, + { + emoji: "🪞", + aliases: ["mirror"], + tags: [], + category: "Objects", + description: "mirror", + unicode_version: "13.0", + }, + { + emoji: "🪟", + aliases: ["window"], + tags: [], + category: "Objects", + description: "window", + unicode_version: "13.0", + }, + { + emoji: "🛏️", + aliases: ["bed"], + tags: [], + category: "Objects", + description: "bed", + unicode_version: "7.0", + }, + { + emoji: "🛋️", + aliases: ["couch_and_lamp"], + tags: [], + category: "Objects", + description: "couch and lamp", + unicode_version: "7.0", + }, + { + emoji: "🪑", + aliases: ["chair"], + tags: [], + category: "Objects", + description: "chair", + unicode_version: "12.0", + }, + { + emoji: "🚽", + aliases: ["toilet"], + tags: ["wc"], + category: "Objects", + description: "toilet", + unicode_version: "6.0", + }, + { + emoji: "🪠", + aliases: ["plunger"], + tags: [], + category: "Objects", + description: "plunger", + unicode_version: "13.0", + }, + { + emoji: "🚿", + aliases: ["shower"], + tags: ["bath"], + category: "Objects", + description: "shower", + unicode_version: "6.0", + }, + { + emoji: "🛁", + aliases: ["bathtub"], + tags: [], + category: "Objects", + description: "bathtub", + unicode_version: "6.0", + }, + { + emoji: "🪤", + aliases: ["mouse_trap"], + tags: [], + category: "Objects", + description: "mouse trap", + unicode_version: "13.0", + }, + { + emoji: "🪒", + aliases: ["razor"], + tags: [], + category: "Objects", + description: "razor", + unicode_version: "12.0", + }, + { + emoji: "🧴", + aliases: ["lotion_bottle"], + tags: [], + category: "Objects", + description: "lotion bottle", + unicode_version: "11.0", + }, + { + emoji: "🧷", + aliases: ["safety_pin"], + tags: [], + category: "Objects", + description: "safety pin", + unicode_version: "11.0", + }, + { + emoji: "🧹", + aliases: ["broom"], + tags: [], + category: "Objects", + description: "broom", + unicode_version: "11.0", + }, + { + emoji: "🧺", + aliases: ["basket"], + tags: [], + category: "Objects", + description: "basket", + unicode_version: "11.0", + }, + { + emoji: "🧻", + aliases: ["roll_of_paper"], + tags: ["toilet"], + category: "Objects", + description: "roll of paper", + unicode_version: "11.0", + }, + { + emoji: "🪣", + aliases: ["bucket"], + tags: [], + category: "Objects", + description: "bucket", + unicode_version: "13.0", + }, + { + emoji: "🧼", + aliases: ["soap"], + tags: [], + category: "Objects", + description: "soap", + unicode_version: "11.0", + }, + { + emoji: "🪥", + aliases: ["toothbrush"], + tags: [], + category: "Objects", + description: "toothbrush", + unicode_version: "13.0", + }, + { + emoji: "🧽", + aliases: ["sponge"], + tags: [], + category: "Objects", + description: "sponge", + unicode_version: "11.0", + }, + { + emoji: "🧯", + aliases: ["fire_extinguisher"], + tags: [], + category: "Objects", + description: "fire extinguisher", + unicode_version: "11.0", + }, + { + emoji: "🛒", + aliases: ["shopping_cart"], + tags: [], + category: "Objects", + description: "shopping cart", + unicode_version: "9.0", + }, + { + emoji: "🚬", + aliases: ["smoking"], + tags: ["cigarette"], + category: "Objects", + description: "cigarette", + unicode_version: "6.0", + }, + { + emoji: "⚰️", + aliases: ["coffin"], + tags: ["funeral"], + category: "Objects", + description: "coffin", + unicode_version: "4.1", + }, + { + emoji: "🪦", + aliases: ["headstone"], + tags: [], + category: "Objects", + description: "headstone", + unicode_version: "13.0", + }, + { + emoji: "⚱️", + aliases: ["funeral_urn"], + tags: [], + category: "Objects", + description: "funeral urn", + unicode_version: "4.1", + }, + { + emoji: "🗿", + aliases: ["moyai"], + tags: ["stone"], + category: "Objects", + description: "moai", + unicode_version: "6.0", + }, + { + emoji: "🪧", + aliases: ["placard"], + tags: [], + category: "Objects", + description: "placard", + unicode_version: "13.0", + }, + { + emoji: "🏧", + aliases: ["atm"], + tags: [], + category: "Symbols", + description: "ATM sign", + unicode_version: "6.0", + }, + { + emoji: "🚮", + aliases: ["put_litter_in_its_place"], + tags: [], + category: "Symbols", + description: "litter in bin sign", + unicode_version: "6.0", + }, + { + emoji: "🚰", + aliases: ["potable_water"], + tags: [], + category: "Symbols", + description: "potable water", + unicode_version: "6.0", + }, + { + emoji: "♿", + aliases: ["wheelchair"], + tags: ["accessibility"], + category: "Symbols", + description: "wheelchair symbol", + unicode_version: "4.1", + }, + { + emoji: "🚹", + aliases: ["mens"], + tags: [], + category: "Symbols", + description: "men’s room", + unicode_version: "6.0", + }, + { + emoji: "🚺", + aliases: ["womens"], + tags: [], + category: "Symbols", + description: "women’s room", + unicode_version: "6.0", + }, + { + emoji: "🚻", + aliases: ["restroom"], + tags: ["toilet"], + category: "Symbols", + description: "restroom", + unicode_version: "6.0", + }, + { + emoji: "🚼", + aliases: ["baby_symbol"], + tags: [], + category: "Symbols", + description: "baby symbol", + unicode_version: "6.0", + }, + { + emoji: "🚾", + aliases: ["wc"], + tags: ["toilet", "restroom"], + category: "Symbols", + description: "water closet", + unicode_version: "6.0", + }, + { + emoji: "🛂", + aliases: ["passport_control"], + tags: [], + category: "Symbols", + description: "passport control", + unicode_version: "6.0", + }, + { + emoji: "🛃", + aliases: ["customs"], + tags: [], + category: "Symbols", + description: "customs", + unicode_version: "6.0", + }, + { + emoji: "🛄", + aliases: ["baggage_claim"], + tags: ["airport"], + category: "Symbols", + description: "baggage claim", + unicode_version: "6.0", + }, + { + emoji: "🛅", + aliases: ["left_luggage"], + tags: [], + category: "Symbols", + description: "left luggage", + unicode_version: "6.0", + }, + { + emoji: "⚠️", + aliases: ["warning"], + tags: ["wip"], + category: "Symbols", + description: "warning", + unicode_version: "4.0", + }, + { + emoji: "🚸", + aliases: ["children_crossing"], + tags: [], + category: "Symbols", + description: "children crossing", + unicode_version: "6.0", + }, + { + emoji: "⛔", + aliases: ["no_entry"], + tags: ["limit"], + category: "Symbols", + description: "no entry", + unicode_version: "5.2", + }, + { + emoji: "🚫", + aliases: ["no_entry_sign"], + tags: ["block", "forbidden"], + category: "Symbols", + description: "prohibited", + unicode_version: "6.0", + }, + { + emoji: "🚳", + aliases: ["no_bicycles"], + tags: [], + category: "Symbols", + description: "no bicycles", + unicode_version: "6.0", + }, + { + emoji: "🚭", + aliases: ["no_smoking"], + tags: [], + category: "Symbols", + description: "no smoking", + unicode_version: "6.0", + }, + { + emoji: "🚯", + aliases: ["do_not_litter"], + tags: [], + category: "Symbols", + description: "no littering", + unicode_version: "6.0", + }, + { + emoji: "🚱", + aliases: ["non-potable_water"], + tags: [], + category: "Symbols", + description: "non-potable water", + unicode_version: "6.0", + }, + { + emoji: "🚷", + aliases: ["no_pedestrians"], + tags: [], + category: "Symbols", + description: "no pedestrians", + unicode_version: "6.0", + }, + { + emoji: "📵", + aliases: ["no_mobile_phones"], + tags: [], + category: "Symbols", + description: "no mobile phones", + unicode_version: "6.0", + }, + { + emoji: "🔞", + aliases: ["underage"], + tags: [], + category: "Symbols", + description: "no one under eighteen", + unicode_version: "6.0", + }, + { + emoji: "☢️", + aliases: ["radioactive"], + tags: [], + category: "Symbols", + description: "radioactive", + unicode_version: "", + }, + { + emoji: "☣️", + aliases: ["biohazard"], + tags: [], + category: "Symbols", + description: "biohazard", + unicode_version: "", + }, + { + emoji: "⬆️", + aliases: ["arrow_up"], + tags: [], + category: "Symbols", + description: "up arrow", + unicode_version: "4.0", + }, + { + emoji: "↗️", + aliases: ["arrow_upper_right"], + tags: [], + category: "Symbols", + description: "up-right arrow", + unicode_version: "", + }, + { + emoji: "➡️", + aliases: ["arrow_right"], + tags: [], + category: "Symbols", + description: "right arrow", + unicode_version: "", + }, + { + emoji: "↘️", + aliases: ["arrow_lower_right"], + tags: [], + category: "Symbols", + description: "down-right arrow", + unicode_version: "", + }, + { + emoji: "⬇️", + aliases: ["arrow_down"], + tags: [], + category: "Symbols", + description: "down arrow", + unicode_version: "4.0", + }, + { + emoji: "↙️", + aliases: ["arrow_lower_left"], + tags: [], + category: "Symbols", + description: "down-left arrow", + unicode_version: "", + }, + { + emoji: "⬅️", + aliases: ["arrow_left"], + tags: [], + category: "Symbols", + description: "left arrow", + unicode_version: "4.0", + }, + { + emoji: "↖️", + aliases: ["arrow_upper_left"], + tags: [], + category: "Symbols", + description: "up-left arrow", + unicode_version: "", + }, + { + emoji: "↕️", + aliases: ["arrow_up_down"], + tags: [], + category: "Symbols", + description: "up-down arrow", + unicode_version: "", + }, + { + emoji: "↔️", + aliases: ["left_right_arrow"], + tags: [], + category: "Symbols", + description: "left-right arrow", + unicode_version: "", + }, + { + emoji: "↩️", + aliases: ["leftwards_arrow_with_hook"], + tags: ["return"], + category: "Symbols", + description: "right arrow curving left", + unicode_version: "", + }, + { + emoji: "↪️", + aliases: ["arrow_right_hook"], + tags: [], + category: "Symbols", + description: "left arrow curving right", + unicode_version: "", + }, + { + emoji: "⤴️", + aliases: ["arrow_heading_up"], + tags: [], + category: "Symbols", + description: "right arrow curving up", + unicode_version: "", + }, + { + emoji: "⤵️", + aliases: ["arrow_heading_down"], + tags: [], + category: "Symbols", + description: "right arrow curving down", + unicode_version: "", + }, + { + emoji: "🔃", + aliases: ["arrows_clockwise"], + tags: [], + category: "Symbols", + description: "clockwise vertical arrows", + unicode_version: "6.0", + }, + { + emoji: "🔄", + aliases: ["arrows_counterclockwise"], + tags: ["sync"], + category: "Symbols", + description: "counterclockwise arrows button", + unicode_version: "6.0", + }, + { + emoji: "🔙", + aliases: ["back"], + tags: [], + category: "Symbols", + description: "BACK arrow", + unicode_version: "6.0", + }, + { + emoji: "🔚", + aliases: ["end"], + tags: [], + category: "Symbols", + description: "END arrow", + unicode_version: "6.0", + }, + { + emoji: "🔛", + aliases: ["on"], + tags: [], + category: "Symbols", + description: "ON! arrow", + unicode_version: "6.0", + }, + { + emoji: "🔜", + aliases: ["soon"], + tags: [], + category: "Symbols", + description: "SOON arrow", + unicode_version: "6.0", + }, + { + emoji: "🔝", + aliases: ["top"], + tags: [], + category: "Symbols", + description: "TOP arrow", + unicode_version: "6.0", + }, + { + emoji: "🛐", + aliases: ["place_of_worship"], + tags: [], + category: "Symbols", + description: "place of worship", + unicode_version: "8.0", + }, + { + emoji: "⚛️", + aliases: ["atom_symbol"], + tags: [], + category: "Symbols", + description: "atom symbol", + unicode_version: "4.1", + }, + { + emoji: "🕉️", + aliases: ["om"], + tags: [], + category: "Symbols", + description: "om", + unicode_version: "7.0", + }, + { + emoji: "✡️", + aliases: ["star_of_david"], + tags: [], + category: "Symbols", + description: "star of David", + unicode_version: "", + }, + { + emoji: "☸️", + aliases: ["wheel_of_dharma"], + tags: [], + category: "Symbols", + description: "wheel of dharma", + unicode_version: "", + }, + { + emoji: "☯️", + aliases: ["yin_yang"], + tags: [], + category: "Symbols", + description: "yin yang", + unicode_version: "", + }, + { + emoji: "✝️", + aliases: ["latin_cross"], + tags: [], + category: "Symbols", + description: "latin cross", + unicode_version: "", + }, + { + emoji: "☦️", + aliases: ["orthodox_cross"], + tags: [], + category: "Symbols", + description: "orthodox cross", + unicode_version: "", + }, + { + emoji: "☪️", + aliases: ["star_and_crescent"], + tags: [], + category: "Symbols", + description: "star and crescent", + unicode_version: "", + }, + { + emoji: "☮️", + aliases: ["peace_symbol"], + tags: [], + category: "Symbols", + description: "peace symbol", + unicode_version: "", + }, + { + emoji: "🕎", + aliases: ["menorah"], + tags: [], + category: "Symbols", + description: "menorah", + unicode_version: "8.0", + }, + { + emoji: "🔯", + aliases: ["six_pointed_star"], + tags: [], + category: "Symbols", + description: "dotted six-pointed star", + unicode_version: "6.0", + }, + { + emoji: "♈", + aliases: ["aries"], + tags: [], + category: "Symbols", + description: "Aries", + unicode_version: "", + }, + { + emoji: "♉", + aliases: ["taurus"], + tags: [], + category: "Symbols", + description: "Taurus", + unicode_version: "", + }, + { + emoji: "♊", + aliases: ["gemini"], + tags: [], + category: "Symbols", + description: "Gemini", + unicode_version: "", + }, + { + emoji: "♋", + aliases: ["cancer"], + tags: [], + category: "Symbols", + description: "Cancer", + unicode_version: "", + }, + { + emoji: "♌", + aliases: ["leo"], + tags: [], + category: "Symbols", + description: "Leo", + unicode_version: "", + }, + { + emoji: "♍", + aliases: ["virgo"], + tags: [], + category: "Symbols", + description: "Virgo", + unicode_version: "", + }, + { + emoji: "♎", + aliases: ["libra"], + tags: [], + category: "Symbols", + description: "Libra", + unicode_version: "", + }, + { + emoji: "♏", + aliases: ["scorpius"], + tags: [], + category: "Symbols", + description: "Scorpio", + unicode_version: "", + }, + { + emoji: "♐", + aliases: ["sagittarius"], + tags: [], + category: "Symbols", + description: "Sagittarius", + unicode_version: "", + }, + { + emoji: "♑", + aliases: ["capricorn"], + tags: [], + category: "Symbols", + description: "Capricorn", + unicode_version: "", + }, + { + emoji: "♒", + aliases: ["aquarius"], + tags: [], + category: "Symbols", + description: "Aquarius", + unicode_version: "", + }, + { + emoji: "♓", + aliases: ["pisces"], + tags: [], + category: "Symbols", + description: "Pisces", + unicode_version: "", + }, + { + emoji: "⛎", + aliases: ["ophiuchus"], + tags: [], + category: "Symbols", + description: "Ophiuchus", + unicode_version: "6.0", + }, + { + emoji: "🔀", + aliases: ["twisted_rightwards_arrows"], + tags: ["shuffle"], + category: "Symbols", + description: "shuffle tracks button", + unicode_version: "6.0", + }, + { + emoji: "🔁", + aliases: ["repeat"], + tags: ["loop"], + category: "Symbols", + description: "repeat button", + unicode_version: "6.0", + }, + { + emoji: "🔂", + aliases: ["repeat_one"], + tags: [], + category: "Symbols", + description: "repeat single button", + unicode_version: "6.0", + }, + { + emoji: "▶️", + aliases: ["arrow_forward"], + tags: [], + category: "Symbols", + description: "play button", + unicode_version: "", + }, + { + emoji: "⏩", + aliases: ["fast_forward"], + tags: [], + category: "Symbols", + description: "fast-forward button", + unicode_version: "6.0", + }, + { + emoji: "⏭️", + aliases: ["next_track_button"], + tags: [], + category: "Symbols", + description: "next track button", + unicode_version: "6.0", + }, + { + emoji: "⏯️", + aliases: ["play_or_pause_button"], + tags: [], + category: "Symbols", + description: "play or pause button", + unicode_version: "6.0", + }, + { + emoji: "◀️", + aliases: ["arrow_backward"], + tags: [], + category: "Symbols", + description: "reverse button", + unicode_version: "", + }, + { + emoji: "⏪", + aliases: ["rewind"], + tags: [], + category: "Symbols", + description: "fast reverse button", + unicode_version: "6.0", + }, + { + emoji: "⏮️", + aliases: ["previous_track_button"], + tags: [], + category: "Symbols", + description: "last track button", + unicode_version: "6.0", + }, + { + emoji: "🔼", + aliases: ["arrow_up_small"], + tags: [], + category: "Symbols", + description: "upwards button", + unicode_version: "6.0", + }, + { + emoji: "⏫", + aliases: ["arrow_double_up"], + tags: [], + category: "Symbols", + description: "fast up button", + unicode_version: "6.0", + }, + { + emoji: "🔽", + aliases: ["arrow_down_small"], + tags: [], + category: "Symbols", + description: "downwards button", + unicode_version: "6.0", + }, + { + emoji: "⏬", + aliases: ["arrow_double_down"], + tags: [], + category: "Symbols", + description: "fast down button", + unicode_version: "6.0", + }, + { + emoji: "⏸️", + aliases: ["pause_button"], + tags: [], + category: "Symbols", + description: "pause button", + unicode_version: "7.0", + }, + { + emoji: "⏹️", + aliases: ["stop_button"], + tags: [], + category: "Symbols", + description: "stop button", + unicode_version: "7.0", + }, + { + emoji: "⏺️", + aliases: ["record_button"], + tags: [], + category: "Symbols", + description: "record button", + unicode_version: "7.0", + }, + { + emoji: "⏏️", + aliases: ["eject_button"], + tags: [], + category: "Symbols", + description: "eject button", + unicode_version: "11.0", + }, + { + emoji: "🎦", + aliases: ["cinema"], + tags: ["film", "movie"], + category: "Symbols", + description: "cinema", + unicode_version: "6.0", + }, + { + emoji: "🔅", + aliases: ["low_brightness"], + tags: [], + category: "Symbols", + description: "dim button", + unicode_version: "6.0", + }, + { + emoji: "🔆", + aliases: ["high_brightness"], + tags: [], + category: "Symbols", + description: "bright button", + unicode_version: "6.0", + }, + { + emoji: "📶", + aliases: ["signal_strength"], + tags: ["wifi"], + category: "Symbols", + description: "antenna bars", + unicode_version: "6.0", + }, + { + emoji: "📳", + aliases: ["vibration_mode"], + tags: [], + category: "Symbols", + description: "vibration mode", + unicode_version: "6.0", + }, + { + emoji: "📴", + aliases: ["mobile_phone_off"], + tags: ["mute", "off"], + category: "Symbols", + description: "mobile phone off", + unicode_version: "6.0", + }, + { + emoji: "♀️", + aliases: ["female_sign"], + tags: [], + category: "Symbols", + description: "female sign", + unicode_version: "11.0", + }, + { + emoji: "♂️", + aliases: ["male_sign"], + tags: [], + category: "Symbols", + description: "male sign", + unicode_version: "11.0", + }, + { + emoji: "⚧️", + aliases: ["transgender_symbol"], + tags: [], + category: "Symbols", + description: "transgender symbol", + unicode_version: "13.0", + }, + { + emoji: "✖️", + aliases: ["heavy_multiplication_x"], + tags: [], + category: "Symbols", + description: "multiply", + unicode_version: "", + }, + { + emoji: "➕", + aliases: ["heavy_plus_sign"], + tags: [], + category: "Symbols", + description: "plus", + unicode_version: "6.0", + }, + { + emoji: "➖", + aliases: ["heavy_minus_sign"], + tags: [], + category: "Symbols", + description: "minus", + unicode_version: "6.0", + }, + { + emoji: "➗", + aliases: ["heavy_division_sign"], + tags: [], + category: "Symbols", + description: "divide", + unicode_version: "6.0", + }, + { + emoji: "♾️", + aliases: ["infinity"], + tags: [], + category: "Symbols", + description: "infinity", + unicode_version: "11.0", + }, + { + emoji: "‼️", + aliases: ["bangbang"], + tags: [], + category: "Symbols", + description: "double exclamation mark", + unicode_version: "", + }, + { + emoji: "⁉️", + aliases: ["interrobang"], + tags: [], + category: "Symbols", + description: "exclamation question mark", + unicode_version: "3.0", + }, + { + emoji: "❓", + aliases: ["question"], + tags: ["confused"], + category: "Symbols", + description: "red question mark", + unicode_version: "6.0", + }, + { + emoji: "❔", + aliases: ["grey_question"], + tags: [], + category: "Symbols", + description: "white question mark", + unicode_version: "6.0", + }, + { + emoji: "❕", + aliases: ["grey_exclamation"], + tags: [], + category: "Symbols", + description: "white exclamation mark", + unicode_version: "6.0", + }, + { + emoji: "❗", + aliases: ["exclamation", "heavy_exclamation_mark"], + tags: ["bang"], + category: "Symbols", + description: "red exclamation mark", + unicode_version: "5.2", + }, + { + emoji: "〰️", + aliases: ["wavy_dash"], + tags: [], + category: "Symbols", + description: "wavy dash", + unicode_version: "", + }, + { + emoji: "💱", + aliases: ["currency_exchange"], + tags: [], + category: "Symbols", + description: "currency exchange", + unicode_version: "6.0", + }, + { + emoji: "💲", + aliases: ["heavy_dollar_sign"], + tags: [], + category: "Symbols", + description: "heavy dollar sign", + unicode_version: "6.0", + }, + { + emoji: "⚕️", + aliases: ["medical_symbol"], + tags: [], + category: "Symbols", + description: "medical symbol", + unicode_version: "11.0", + }, + { + emoji: "♻️", + aliases: ["recycle"], + tags: ["environment", "green"], + category: "Symbols", + description: "recycling symbol", + unicode_version: "3.2", + }, + { + emoji: "⚜️", + aliases: ["fleur_de_lis"], + tags: [], + category: "Symbols", + description: "fleur-de-lis", + unicode_version: "4.1", + }, + { + emoji: "🔱", + aliases: ["trident"], + tags: [], + category: "Symbols", + description: "trident emblem", + unicode_version: "6.0", + }, + { + emoji: "📛", + aliases: ["name_badge"], + tags: [], + category: "Symbols", + description: "name badge", + unicode_version: "6.0", + }, + { + emoji: "🔰", + aliases: ["beginner"], + tags: [], + category: "Symbols", + description: "Japanese symbol for beginner", + unicode_version: "6.0", + }, + { + emoji: "⭕", + aliases: ["o"], + tags: [], + category: "Symbols", + description: "hollow red circle", + unicode_version: "5.2", + }, + { + emoji: "✅", + aliases: ["white_check_mark"], + tags: [], + category: "Symbols", + description: "check mark button", + unicode_version: "6.0", + }, + { + emoji: "☑️", + aliases: ["ballot_box_with_check"], + tags: [], + category: "Symbols", + description: "check box with check", + unicode_version: "", + }, + { + emoji: "✔️", + aliases: ["heavy_check_mark"], + tags: [], + category: "Symbols", + description: "check mark", + unicode_version: "", + }, + { + emoji: "❌", + aliases: ["x"], + tags: [], + category: "Symbols", + description: "cross mark", + unicode_version: "6.0", + }, + { + emoji: "❎", + aliases: ["negative_squared_cross_mark"], + tags: [], + category: "Symbols", + description: "cross mark button", + unicode_version: "6.0", + }, + { + emoji: "➰", + aliases: ["curly_loop"], + tags: [], + category: "Symbols", + description: "curly loop", + unicode_version: "6.0", + }, + { + emoji: "➿", + aliases: ["loop"], + tags: [], + category: "Symbols", + description: "double curly loop", + unicode_version: "6.0", + }, + { + emoji: "〽️", + aliases: ["part_alternation_mark"], + tags: [], + category: "Symbols", + description: "part alternation mark", + unicode_version: "3.2", + }, + { + emoji: "✳️", + aliases: ["eight_spoked_asterisk"], + tags: [], + category: "Symbols", + description: "eight-spoked asterisk", + unicode_version: "", + }, + { + emoji: "✴️", + aliases: ["eight_pointed_black_star"], + tags: [], + category: "Symbols", + description: "eight-pointed star", + unicode_version: "", + }, + { + emoji: "❇️", + aliases: ["sparkle"], + tags: [], + category: "Symbols", + description: "sparkle", + unicode_version: "", + }, + { + emoji: "©️", + aliases: ["copyright"], + tags: [], + category: "Symbols", + description: "copyright", + unicode_version: "", + }, + { + emoji: "®️", + aliases: ["registered"], + tags: [], + category: "Symbols", + description: "registered", + unicode_version: "", + }, + { + emoji: "™️", + aliases: ["tm"], + tags: ["trademark"], + category: "Symbols", + description: "trade mark", + unicode_version: "", + }, + { + emoji: "#️⃣", + aliases: ["hash"], + tags: ["number"], + category: "Symbols", + description: "keycap: #", + unicode_version: "", + }, + { + emoji: "*️⃣", + aliases: ["asterisk"], + tags: [], + category: "Symbols", + description: "keycap: *", + unicode_version: "", + }, + { + emoji: "0️⃣", + aliases: ["zero"], + tags: [], + category: "Symbols", + description: "keycap: 0", + unicode_version: "", + }, + { + emoji: "1️⃣", + aliases: ["one"], + tags: [], + category: "Symbols", + description: "keycap: 1", + unicode_version: "", + }, + { + emoji: "2️⃣", + aliases: ["two"], + tags: [], + category: "Symbols", + description: "keycap: 2", + unicode_version: "", + }, + { + emoji: "3️⃣", + aliases: ["three"], + tags: [], + category: "Symbols", + description: "keycap: 3", + unicode_version: "", + }, + { + emoji: "4️⃣", + aliases: ["four"], + tags: [], + category: "Symbols", + description: "keycap: 4", + unicode_version: "", + }, + { + emoji: "5️⃣", + aliases: ["five"], + tags: [], + category: "Symbols", + description: "keycap: 5", + unicode_version: "", + }, + { + emoji: "6️⃣", + aliases: ["six"], + tags: [], + category: "Symbols", + description: "keycap: 6", + unicode_version: "", + }, + { + emoji: "7️⃣", + aliases: ["seven"], + tags: [], + category: "Symbols", + description: "keycap: 7", + unicode_version: "", + }, + { + emoji: "8️⃣", + aliases: ["eight"], + tags: [], + category: "Symbols", + description: "keycap: 8", + unicode_version: "", + }, + { + emoji: "9️⃣", + aliases: ["nine"], + tags: [], + category: "Symbols", + description: "keycap: 9", + unicode_version: "", + }, + { + emoji: "🔟", + aliases: ["keycap_ten"], + tags: [], + category: "Symbols", + description: "keycap: 10", + unicode_version: "6.0", + }, + { + emoji: "🔠", + aliases: ["capital_abcd"], + tags: ["letters"], + category: "Symbols", + description: "input latin uppercase", + unicode_version: "6.0", + }, + { + emoji: "🔡", + aliases: ["abcd"], + tags: [], + category: "Symbols", + description: "input latin lowercase", + unicode_version: "6.0", + }, + { + emoji: "🔢", + aliases: ["1234"], + tags: ["numbers"], + category: "Symbols", + description: "input numbers", + unicode_version: "6.0", + }, + { + emoji: "🔣", + aliases: ["symbols"], + tags: [], + category: "Symbols", + description: "input symbols", + unicode_version: "6.0", + }, + { + emoji: "🔤", + aliases: ["abc"], + tags: ["alphabet"], + category: "Symbols", + description: "input latin letters", + unicode_version: "6.0", + }, + { + emoji: "🅰️", + aliases: ["a"], + tags: [], + category: "Symbols", + description: "A button (blood type)", + unicode_version: "6.0", + }, + { + emoji: "🆎", + aliases: ["ab"], + tags: [], + category: "Symbols", + description: "AB button (blood type)", + unicode_version: "6.0", + }, + { + emoji: "🅱️", + aliases: ["b"], + tags: [], + category: "Symbols", + description: "B button (blood type)", + unicode_version: "6.0", + }, + { + emoji: "🆑", + aliases: ["cl"], + tags: [], + category: "Symbols", + description: "CL button", + unicode_version: "6.0", + }, + { + emoji: "🆒", + aliases: ["cool"], + tags: [], + category: "Symbols", + description: "COOL button", + unicode_version: "6.0", + }, + { + emoji: "🆓", + aliases: ["free"], + tags: [], + category: "Symbols", + description: "FREE button", + unicode_version: "6.0", + }, + { + emoji: "ℹ️", + aliases: ["information_source"], + tags: [], + category: "Symbols", + description: "information", + unicode_version: "3.0", + }, + { + emoji: "🆔", + aliases: ["id"], + tags: [], + category: "Symbols", + description: "ID button", + unicode_version: "6.0", + }, + { + emoji: "Ⓜ️", + aliases: ["m"], + tags: [], + category: "Symbols", + description: "circled M", + unicode_version: "", + }, + { + emoji: "🆕", + aliases: ["new"], + tags: ["fresh"], + category: "Symbols", + description: "NEW button", + unicode_version: "6.0", + }, + { + emoji: "🆖", + aliases: ["ng"], + tags: [], + category: "Symbols", + description: "NG button", + unicode_version: "6.0", + }, + { + emoji: "🅾️", + aliases: ["o2"], + tags: [], + category: "Symbols", + description: "O button (blood type)", + unicode_version: "6.0", + }, + { + emoji: "🆗", + aliases: ["ok"], + tags: ["yes"], + category: "Symbols", + description: "OK button", + unicode_version: "6.0", + }, + { + emoji: "🅿️", + aliases: ["parking"], + tags: [], + category: "Symbols", + description: "P button", + unicode_version: "5.2", + }, + { + emoji: "🆘", + aliases: ["sos"], + tags: ["help", "emergency"], + category: "Symbols", + description: "SOS button", + unicode_version: "6.0", + }, + { + emoji: "🆙", + aliases: ["up"], + tags: [], + category: "Symbols", + description: "UP! button", + unicode_version: "6.0", + }, + { + emoji: "🆚", + aliases: ["vs"], + tags: [], + category: "Symbols", + description: "VS button", + unicode_version: "6.0", + }, + { + emoji: "🈁", + aliases: ["koko"], + tags: [], + category: "Symbols", + description: "Japanese “here” button", + unicode_version: "6.0", + }, + { + emoji: "🈂️", + aliases: ["sa"], + tags: [], + category: "Symbols", + description: "Japanese “service charge” button", + unicode_version: "6.0", + }, + { + emoji: "🈷️", + aliases: ["u6708"], + tags: [], + category: "Symbols", + description: "Japanese “monthly amount” button", + unicode_version: "6.0", + }, + { + emoji: "🈶", + aliases: ["u6709"], + tags: [], + category: "Symbols", + description: "Japanese “not free of charge” button", + unicode_version: "6.0", + }, + { + emoji: "🈯", + aliases: ["u6307"], + tags: [], + category: "Symbols", + description: "Japanese “reserved” button", + unicode_version: "", + }, + { + emoji: "🉐", + aliases: ["ideograph_advantage"], + tags: [], + category: "Symbols", + description: "Japanese “bargain” button", + unicode_version: "6.0", + }, + { + emoji: "🈹", + aliases: ["u5272"], + tags: [], + category: "Symbols", + description: "Japanese “discount” button", + unicode_version: "6.0", + }, + { + emoji: "🈚", + aliases: ["u7121"], + tags: [], + category: "Symbols", + description: "Japanese “free of charge” button", + unicode_version: "", + }, + { + emoji: "🈲", + aliases: ["u7981"], + tags: [], + category: "Symbols", + description: "Japanese “prohibited” button", + unicode_version: "6.0", + }, + { + emoji: "🉑", + aliases: ["accept"], + tags: [], + category: "Symbols", + description: "Japanese “acceptable” button", + unicode_version: "6.0", + }, + { + emoji: "🈸", + aliases: ["u7533"], + tags: [], + category: "Symbols", + description: "Japanese “application” button", + unicode_version: "6.0", + }, + { + emoji: "🈴", + aliases: ["u5408"], + tags: [], + category: "Symbols", + description: "Japanese “passing grade” button", + unicode_version: "6.0", + }, + { + emoji: "🈳", + aliases: ["u7a7a"], + tags: [], + category: "Symbols", + description: "Japanese “vacancy” button", + unicode_version: "6.0", + }, + { + emoji: "㊗️", + aliases: ["congratulations"], + tags: [], + category: "Symbols", + description: "Japanese “congratulations” button", + unicode_version: "", + }, + { + emoji: "㊙️", + aliases: ["secret"], + tags: [], + category: "Symbols", + description: "Japanese “secret” button", + unicode_version: "", + }, + { + emoji: "🈺", + aliases: ["u55b6"], + tags: [], + category: "Symbols", + description: "Japanese “open for business” button", + unicode_version: "6.0", + }, + { + emoji: "🈵", + aliases: ["u6e80"], + tags: [], + category: "Symbols", + description: "Japanese “no vacancy” button", + unicode_version: "6.0", + }, + { + emoji: "🔴", + aliases: ["red_circle"], + tags: [], + category: "Symbols", + description: "red circle", + unicode_version: "6.0", + }, + { + emoji: "🟠", + aliases: ["orange_circle"], + tags: [], + category: "Symbols", + description: "orange circle", + unicode_version: "12.0", + }, + { + emoji: "🟡", + aliases: ["yellow_circle"], + tags: [], + category: "Symbols", + description: "yellow circle", + unicode_version: "12.0", + }, + { + emoji: "🟢", + aliases: ["green_circle"], + tags: [], + category: "Symbols", + description: "green circle", + unicode_version: "12.0", + }, + { + emoji: "🔵", + aliases: ["large_blue_circle"], + tags: [], + category: "Symbols", + description: "blue circle", + unicode_version: "6.0", + }, + { + emoji: "🟣", + aliases: ["purple_circle"], + tags: [], + category: "Symbols", + description: "purple circle", + unicode_version: "12.0", + }, + { + emoji: "🟤", + aliases: ["brown_circle"], + tags: [], + category: "Symbols", + description: "brown circle", + unicode_version: "12.0", + }, + { + emoji: "⚫", + aliases: ["black_circle"], + tags: [], + category: "Symbols", + description: "black circle", + unicode_version: "4.1", + }, + { + emoji: "⚪", + aliases: ["white_circle"], + tags: [], + category: "Symbols", + description: "white circle", + unicode_version: "4.1", + }, + { + emoji: "🟥", + aliases: ["red_square"], + tags: [], + category: "Symbols", + description: "red square", + unicode_version: "12.0", + }, + { + emoji: "🟧", + aliases: ["orange_square"], + tags: [], + category: "Symbols", + description: "orange square", + unicode_version: "12.0", + }, + { + emoji: "🟨", + aliases: ["yellow_square"], + tags: [], + category: "Symbols", + description: "yellow square", + unicode_version: "12.0", + }, + { + emoji: "🟩", + aliases: ["green_square"], + tags: [], + category: "Symbols", + description: "green square", + unicode_version: "12.0", + }, + { + emoji: "🟦", + aliases: ["blue_square"], + tags: [], + category: "Symbols", + description: "blue square", + unicode_version: "12.0", + }, + { + emoji: "🟪", + aliases: ["purple_square"], + tags: [], + category: "Symbols", + description: "purple square", + unicode_version: "12.0", + }, + { + emoji: "🟫", + aliases: ["brown_square"], + tags: [], + category: "Symbols", + description: "brown square", + unicode_version: "12.0", + }, + { + emoji: "⬛", + aliases: ["black_large_square"], + tags: [], + category: "Symbols", + description: "black large square", + unicode_version: "5.1", + }, + { + emoji: "⬜", + aliases: ["white_large_square"], + tags: [], + category: "Symbols", + description: "white large square", + unicode_version: "5.1", + }, + { + emoji: "◼️", + aliases: ["black_medium_square"], + tags: [], + category: "Symbols", + description: "black medium square", + unicode_version: "3.2", + }, + { + emoji: "◻️", + aliases: ["white_medium_square"], + tags: [], + category: "Symbols", + description: "white medium square", + unicode_version: "3.2", + }, + { + emoji: "◾", + aliases: ["black_medium_small_square"], + tags: [], + category: "Symbols", + description: "black medium-small square", + unicode_version: "3.2", + }, + { + emoji: "◽", + aliases: ["white_medium_small_square"], + tags: [], + category: "Symbols", + description: "white medium-small square", + unicode_version: "3.2", + }, + { + emoji: "▪️", + aliases: ["black_small_square"], + tags: [], + category: "Symbols", + description: "black small square", + unicode_version: "", + }, + { + emoji: "▫️", + aliases: ["white_small_square"], + tags: [], + category: "Symbols", + description: "white small square", + unicode_version: "", + }, + { + emoji: "🔶", + aliases: ["large_orange_diamond"], + tags: [], + category: "Symbols", + description: "large orange diamond", + unicode_version: "6.0", + }, + { + emoji: "🔷", + aliases: ["large_blue_diamond"], + tags: [], + category: "Symbols", + description: "large blue diamond", + unicode_version: "6.0", + }, + { + emoji: "🔸", + aliases: ["small_orange_diamond"], + tags: [], + category: "Symbols", + description: "small orange diamond", + unicode_version: "6.0", + }, + { + emoji: "🔹", + aliases: ["small_blue_diamond"], + tags: [], + category: "Symbols", + description: "small blue diamond", + unicode_version: "6.0", + }, + { + emoji: "🔺", + aliases: ["small_red_triangle"], + tags: [], + category: "Symbols", + description: "red triangle pointed up", + unicode_version: "6.0", + }, + { + emoji: "🔻", + aliases: ["small_red_triangle_down"], + tags: [], + category: "Symbols", + description: "red triangle pointed down", + unicode_version: "6.0", + }, + { + emoji: "💠", + aliases: ["diamond_shape_with_a_dot_inside"], + tags: [], + category: "Symbols", + description: "diamond with a dot", + unicode_version: "6.0", + }, + { + emoji: "🔘", + aliases: ["radio_button"], + tags: [], + category: "Symbols", + description: "radio button", + unicode_version: "6.0", + }, + { + emoji: "🔳", + aliases: ["white_square_button"], + tags: [], + category: "Symbols", + description: "white square button", + unicode_version: "6.0", + }, + { + emoji: "🔲", + aliases: ["black_square_button"], + tags: [], + category: "Symbols", + description: "black square button", + unicode_version: "6.0", + }, + { + emoji: "🏁", + aliases: ["checkered_flag"], + tags: ["milestone", "finish"], + category: "Flags", + description: "chequered flag", + unicode_version: "6.0", + }, + { + emoji: "🚩", + aliases: ["triangular_flag_on_post"], + tags: [], + category: "Flags", + description: "triangular flag", + unicode_version: "6.0", + }, + { + emoji: "🎌", + aliases: ["crossed_flags"], + tags: [], + category: "Flags", + description: "crossed flags", + unicode_version: "6.0", + }, + { + emoji: "🏴", + aliases: ["black_flag"], + tags: [], + category: "Flags", + description: "black flag", + unicode_version: "7.0", + }, + { + emoji: "🏳️", + aliases: ["white_flag"], + tags: [], + category: "Flags", + description: "white flag", + unicode_version: "7.0", + }, + { + emoji: "🏳️‍🌈", + aliases: ["rainbow_flag"], + tags: ["pride"], + category: "Flags", + description: "rainbow flag", + unicode_version: "6.0", + }, + { + emoji: "🏳️‍⚧️", + aliases: ["transgender_flag"], + tags: [], + category: "Flags", + description: "transgender flag", + unicode_version: "13.0", + }, + { + emoji: "🏴‍☠️", + aliases: ["pirate_flag"], + tags: [], + category: "Flags", + description: "pirate flag", + unicode_version: "11.0", + }, + { + emoji: "🇦🇨", + aliases: ["ascension_island"], + tags: [], + category: "Flags", + description: "flag: Ascension Island", + unicode_version: "11.0", + }, + { + emoji: "🇦🇩", + aliases: ["andorra"], + tags: [], + category: "Flags", + description: "flag: Andorra", + unicode_version: "6.0", + }, + { + emoji: "🇦🇪", + aliases: ["united_arab_emirates"], + tags: [], + category: "Flags", + description: "flag: United Arab Emirates", + unicode_version: "6.0", + }, + { + emoji: "🇦🇫", + aliases: ["afghanistan"], + tags: [], + category: "Flags", + description: "flag: Afghanistan", + unicode_version: "6.0", + }, + { + emoji: "🇦🇬", + aliases: ["antigua_barbuda"], + tags: [], + category: "Flags", + description: "flag: Antigua & Barbuda", + unicode_version: "6.0", + }, + { + emoji: "🇦🇮", + aliases: ["anguilla"], + tags: [], + category: "Flags", + description: "flag: Anguilla", + unicode_version: "6.0", + }, + { + emoji: "🇦🇱", + aliases: ["albania"], + tags: [], + category: "Flags", + description: "flag: Albania", + unicode_version: "6.0", + }, + { + emoji: "🇦🇲", + aliases: ["armenia"], + tags: [], + category: "Flags", + description: "flag: Armenia", + unicode_version: "6.0", + }, + { + emoji: "🇦🇴", + aliases: ["angola"], + tags: [], + category: "Flags", + description: "flag: Angola", + unicode_version: "6.0", + }, + { + emoji: "🇦🇶", + aliases: ["antarctica"], + tags: [], + category: "Flags", + description: "flag: Antarctica", + unicode_version: "6.0", + }, + { + emoji: "🇦🇷", + aliases: ["argentina"], + tags: [], + category: "Flags", + description: "flag: Argentina", + unicode_version: "6.0", + }, + { + emoji: "🇦🇸", + aliases: ["american_samoa"], + tags: [], + category: "Flags", + description: "flag: American Samoa", + unicode_version: "6.0", + }, + { + emoji: "🇦🇹", + aliases: ["austria"], + tags: [], + category: "Flags", + description: "flag: Austria", + unicode_version: "6.0", + }, + { + emoji: "🇦🇺", + aliases: ["australia"], + tags: [], + category: "Flags", + description: "flag: Australia", + unicode_version: "6.0", + }, + { + emoji: "🇦🇼", + aliases: ["aruba"], + tags: [], + category: "Flags", + description: "flag: Aruba", + unicode_version: "6.0", + }, + { + emoji: "🇦🇽", + aliases: ["aland_islands"], + tags: [], + category: "Flags", + description: "flag: Åland Islands", + unicode_version: "6.0", + }, + { + emoji: "🇦🇿", + aliases: ["azerbaijan"], + tags: [], + category: "Flags", + description: "flag: Azerbaijan", + unicode_version: "6.0", + }, + { + emoji: "🇧🇦", + aliases: ["bosnia_herzegovina"], + tags: [], + category: "Flags", + description: "flag: Bosnia & Herzegovina", + unicode_version: "6.0", + }, + { + emoji: "🇧🇧", + aliases: ["barbados"], + tags: [], + category: "Flags", + description: "flag: Barbados", + unicode_version: "6.0", + }, + { + emoji: "🇧🇩", + aliases: ["bangladesh"], + tags: [], + category: "Flags", + description: "flag: Bangladesh", + unicode_version: "6.0", + }, + { + emoji: "🇧🇪", + aliases: ["belgium"], + tags: [], + category: "Flags", + description: "flag: Belgium", + unicode_version: "6.0", + }, + { + emoji: "🇧🇫", + aliases: ["burkina_faso"], + tags: [], + category: "Flags", + description: "flag: Burkina Faso", + unicode_version: "6.0", + }, + { + emoji: "🇧🇬", + aliases: ["bulgaria"], + tags: [], + category: "Flags", + description: "flag: Bulgaria", + unicode_version: "6.0", + }, + { + emoji: "🇧🇭", + aliases: ["bahrain"], + tags: [], + category: "Flags", + description: "flag: Bahrain", + unicode_version: "6.0", + }, + { + emoji: "🇧🇮", + aliases: ["burundi"], + tags: [], + category: "Flags", + description: "flag: Burundi", + unicode_version: "6.0", + }, + { + emoji: "🇧🇯", + aliases: ["benin"], + tags: [], + category: "Flags", + description: "flag: Benin", + unicode_version: "6.0", + }, + { + emoji: "🇧🇱", + aliases: ["st_barthelemy"], + tags: [], + category: "Flags", + description: "flag: St. Barthélemy", + unicode_version: "6.0", + }, + { + emoji: "🇧🇲", + aliases: ["bermuda"], + tags: [], + category: "Flags", + description: "flag: Bermuda", + unicode_version: "6.0", + }, + { + emoji: "🇧🇳", + aliases: ["brunei"], + tags: [], + category: "Flags", + description: "flag: Brunei", + unicode_version: "6.0", + }, + { + emoji: "🇧🇴", + aliases: ["bolivia"], + tags: [], + category: "Flags", + description: "flag: Bolivia", + unicode_version: "6.0", + }, + { + emoji: "🇧🇶", + aliases: ["caribbean_netherlands"], + tags: [], + category: "Flags", + description: "flag: Caribbean Netherlands", + unicode_version: "6.0", + }, + { + emoji: "🇧🇷", + aliases: ["brazil"], + tags: [], + category: "Flags", + description: "flag: Brazil", + unicode_version: "6.0", + }, + { + emoji: "🇧🇸", + aliases: ["bahamas"], + tags: [], + category: "Flags", + description: "flag: Bahamas", + unicode_version: "6.0", + }, + { + emoji: "🇧🇹", + aliases: ["bhutan"], + tags: [], + category: "Flags", + description: "flag: Bhutan", + unicode_version: "6.0", + }, + { + emoji: "🇧🇻", + aliases: ["bouvet_island"], + tags: [], + category: "Flags", + description: "flag: Bouvet Island", + unicode_version: "11.0", + }, + { + emoji: "🇧🇼", + aliases: ["botswana"], + tags: [], + category: "Flags", + description: "flag: Botswana", + unicode_version: "6.0", + }, + { + emoji: "🇧🇾", + aliases: ["belarus"], + tags: [], + category: "Flags", + description: "flag: Belarus", + unicode_version: "6.0", + }, + { + emoji: "🇧🇿", + aliases: ["belize"], + tags: [], + category: "Flags", + description: "flag: Belize", + unicode_version: "6.0", + }, + { + emoji: "🇨🇦", + aliases: ["canada"], + tags: [], + category: "Flags", + description: "flag: Canada", + unicode_version: "6.0", + }, + { + emoji: "🇨🇨", + aliases: ["cocos_islands"], + tags: ["keeling"], + category: "Flags", + description: "flag: Cocos (Keeling) Islands", + unicode_version: "6.0", + }, + { + emoji: "🇨🇩", + aliases: ["congo_kinshasa"], + tags: [], + category: "Flags", + description: "flag: Congo - Kinshasa", + unicode_version: "6.0", + }, + { + emoji: "🇨🇫", + aliases: ["central_african_republic"], + tags: [], + category: "Flags", + description: "flag: Central African Republic", + unicode_version: "6.0", + }, + { + emoji: "🇨🇬", + aliases: ["congo_brazzaville"], + tags: [], + category: "Flags", + description: "flag: Congo - Brazzaville", + unicode_version: "6.0", + }, + { + emoji: "🇨🇭", + aliases: ["switzerland"], + tags: [], + category: "Flags", + description: "flag: Switzerland", + unicode_version: "6.0", + }, + { + emoji: "🇨🇮", + aliases: ["cote_divoire"], + tags: ["ivory"], + category: "Flags", + description: "flag: Côte d’Ivoire", + unicode_version: "6.0", + }, + { + emoji: "🇨🇰", + aliases: ["cook_islands"], + tags: [], + category: "Flags", + description: "flag: Cook Islands", + unicode_version: "6.0", + }, + { + emoji: "🇨🇱", + aliases: ["chile"], + tags: [], + category: "Flags", + description: "flag: Chile", + unicode_version: "6.0", + }, + { + emoji: "🇨🇲", + aliases: ["cameroon"], + tags: [], + category: "Flags", + description: "flag: Cameroon", + unicode_version: "6.0", + }, + { + emoji: "🇨🇳", + aliases: ["cn"], + tags: ["china"], + category: "Flags", + description: "flag: China", + unicode_version: "6.0", + }, + { + emoji: "🇨🇴", + aliases: ["colombia"], + tags: [], + category: "Flags", + description: "flag: Colombia", + unicode_version: "6.0", + }, + { + emoji: "🇨🇵", + aliases: ["clipperton_island"], + tags: [], + category: "Flags", + description: "flag: Clipperton Island", + unicode_version: "11.0", + }, + { + emoji: "🇨🇷", + aliases: ["costa_rica"], + tags: [], + category: "Flags", + description: "flag: Costa Rica", + unicode_version: "6.0", + }, + { + emoji: "🇨🇺", + aliases: ["cuba"], + tags: [], + category: "Flags", + description: "flag: Cuba", + unicode_version: "6.0", + }, + { + emoji: "🇨🇻", + aliases: ["cape_verde"], + tags: [], + category: "Flags", + description: "flag: Cape Verde", + unicode_version: "6.0", + }, + { + emoji: "🇨🇼", + aliases: ["curacao"], + tags: [], + category: "Flags", + description: "flag: Curaçao", + unicode_version: "6.0", + }, + { + emoji: "🇨🇽", + aliases: ["christmas_island"], + tags: [], + category: "Flags", + description: "flag: Christmas Island", + unicode_version: "6.0", + }, + { + emoji: "🇨🇾", + aliases: ["cyprus"], + tags: [], + category: "Flags", + description: "flag: Cyprus", + unicode_version: "6.0", + }, + { + emoji: "🇨🇿", + aliases: ["czech_republic"], + tags: [], + category: "Flags", + description: "flag: Czechia", + unicode_version: "6.0", + }, + { + emoji: "🇩🇪", + aliases: ["de"], + tags: ["flag", "germany"], + category: "Flags", + description: "flag: Germany", + unicode_version: "6.0", + }, + { + emoji: "🇩🇬", + aliases: ["diego_garcia"], + tags: [], + category: "Flags", + description: "flag: Diego Garcia", + unicode_version: "11.0", + }, + { + emoji: "🇩🇯", + aliases: ["djibouti"], + tags: [], + category: "Flags", + description: "flag: Djibouti", + unicode_version: "6.0", + }, + { + emoji: "🇩🇰", + aliases: ["denmark"], + tags: [], + category: "Flags", + description: "flag: Denmark", + unicode_version: "6.0", + }, + { + emoji: "🇩🇲", + aliases: ["dominica"], + tags: [], + category: "Flags", + description: "flag: Dominica", + unicode_version: "6.0", + }, + { + emoji: "🇩🇴", + aliases: ["dominican_republic"], + tags: [], + category: "Flags", + description: "flag: Dominican Republic", + unicode_version: "6.0", + }, + { + emoji: "🇩🇿", + aliases: ["algeria"], + tags: [], + category: "Flags", + description: "flag: Algeria", + unicode_version: "6.0", + }, + { + emoji: "🇪🇦", + aliases: ["ceuta_melilla"], + tags: [], + category: "Flags", + description: "flag: Ceuta & Melilla", + unicode_version: "11.0", + }, + { + emoji: "🇪🇨", + aliases: ["ecuador"], + tags: [], + category: "Flags", + description: "flag: Ecuador", + unicode_version: "6.0", + }, + { + emoji: "🇪🇪", + aliases: ["estonia"], + tags: [], + category: "Flags", + description: "flag: Estonia", + unicode_version: "6.0", + }, + { + emoji: "🇪🇬", + aliases: ["egypt"], + tags: [], + category: "Flags", + description: "flag: Egypt", + unicode_version: "6.0", + }, + { + emoji: "🇪🇭", + aliases: ["western_sahara"], + tags: [], + category: "Flags", + description: "flag: Western Sahara", + unicode_version: "6.0", + }, + { + emoji: "🇪🇷", + aliases: ["eritrea"], + tags: [], + category: "Flags", + description: "flag: Eritrea", + unicode_version: "6.0", + }, + { + emoji: "🇪🇸", + aliases: ["es"], + tags: ["spain"], + category: "Flags", + description: "flag: Spain", + unicode_version: "6.0", + }, + { + emoji: "🇪🇹", + aliases: ["ethiopia"], + tags: [], + category: "Flags", + description: "flag: Ethiopia", + unicode_version: "6.0", + }, + { + emoji: "🇪🇺", + aliases: ["eu", "european_union"], + tags: [], + category: "Flags", + description: "flag: European Union", + unicode_version: "6.0", + }, + { + emoji: "🇫🇮", + aliases: ["finland"], + tags: [], + category: "Flags", + description: "flag: Finland", + unicode_version: "6.0", + }, + { + emoji: "🇫🇯", + aliases: ["fiji"], + tags: [], + category: "Flags", + description: "flag: Fiji", + unicode_version: "6.0", + }, + { + emoji: "🇫🇰", + aliases: ["falkland_islands"], + tags: [], + category: "Flags", + description: "flag: Falkland Islands", + unicode_version: "6.0", + }, + { + emoji: "🇫🇲", + aliases: ["micronesia"], + tags: [], + category: "Flags", + description: "flag: Micronesia", + unicode_version: "6.0", + }, + { + emoji: "🇫🇴", + aliases: ["faroe_islands"], + tags: [], + category: "Flags", + description: "flag: Faroe Islands", + unicode_version: "6.0", + }, + { + emoji: "🇫🇷", + aliases: ["fr"], + tags: ["france", "french"], + category: "Flags", + description: "flag: France", + unicode_version: "6.0", + }, + { + emoji: "🇬🇦", + aliases: ["gabon"], + tags: [], + category: "Flags", + description: "flag: Gabon", + unicode_version: "6.0", + }, + { + emoji: "🇬🇧", + aliases: ["gb", "uk"], + tags: ["flag", "british"], + category: "Flags", + description: "flag: United Kingdom", + unicode_version: "6.0", + }, + { + emoji: "🇬🇩", + aliases: ["grenada"], + tags: [], + category: "Flags", + description: "flag: Grenada", + unicode_version: "6.0", + }, + { + emoji: "🇬🇪", + aliases: ["georgia"], + tags: [], + category: "Flags", + description: "flag: Georgia", + unicode_version: "6.0", + }, + { + emoji: "🇬🇫", + aliases: ["french_guiana"], + tags: [], + category: "Flags", + description: "flag: French Guiana", + unicode_version: "6.0", + }, + { + emoji: "🇬🇬", + aliases: ["guernsey"], + tags: [], + category: "Flags", + description: "flag: Guernsey", + unicode_version: "6.0", + }, + { + emoji: "🇬🇭", + aliases: ["ghana"], + tags: [], + category: "Flags", + description: "flag: Ghana", + unicode_version: "6.0", + }, + { + emoji: "🇬🇮", + aliases: ["gibraltar"], + tags: [], + category: "Flags", + description: "flag: Gibraltar", + unicode_version: "6.0", + }, + { + emoji: "🇬🇱", + aliases: ["greenland"], + tags: [], + category: "Flags", + description: "flag: Greenland", + unicode_version: "6.0", + }, + { + emoji: "🇬🇲", + aliases: ["gambia"], + tags: [], + category: "Flags", + description: "flag: Gambia", + unicode_version: "6.0", + }, + { + emoji: "🇬🇳", + aliases: ["guinea"], + tags: [], + category: "Flags", + description: "flag: Guinea", + unicode_version: "6.0", + }, + { + emoji: "🇬🇵", + aliases: ["guadeloupe"], + tags: [], + category: "Flags", + description: "flag: Guadeloupe", + unicode_version: "6.0", + }, + { + emoji: "🇬🇶", + aliases: ["equatorial_guinea"], + tags: [], + category: "Flags", + description: "flag: Equatorial Guinea", + unicode_version: "6.0", + }, + { + emoji: "🇬🇷", + aliases: ["greece"], + tags: [], + category: "Flags", + description: "flag: Greece", + unicode_version: "6.0", + }, + { + emoji: "🇬🇸", + aliases: ["south_georgia_south_sandwich_islands"], + tags: [], + category: "Flags", + description: "flag: South Georgia & South Sandwich Islands", + unicode_version: "6.0", + }, + { + emoji: "🇬🇹", + aliases: ["guatemala"], + tags: [], + category: "Flags", + description: "flag: Guatemala", + unicode_version: "6.0", + }, + { + emoji: "🇬🇺", + aliases: ["guam"], + tags: [], + category: "Flags", + description: "flag: Guam", + unicode_version: "6.0", + }, + { + emoji: "🇬🇼", + aliases: ["guinea_bissau"], + tags: [], + category: "Flags", + description: "flag: Guinea-Bissau", + unicode_version: "6.0", + }, + { + emoji: "🇬🇾", + aliases: ["guyana"], + tags: [], + category: "Flags", + description: "flag: Guyana", + unicode_version: "6.0", + }, + { + emoji: "🇭🇰", + aliases: ["hong_kong"], + tags: [], + category: "Flags", + description: "flag: Hong Kong SAR China", + unicode_version: "6.0", + }, + { + emoji: "🇭🇲", + aliases: ["heard_mcdonald_islands"], + tags: [], + category: "Flags", + description: "flag: Heard & McDonald Islands", + unicode_version: "11.0", + }, + { + emoji: "🇭🇳", + aliases: ["honduras"], + tags: [], + category: "Flags", + description: "flag: Honduras", + unicode_version: "6.0", + }, + { + emoji: "🇭🇷", + aliases: ["croatia"], + tags: [], + category: "Flags", + description: "flag: Croatia", + unicode_version: "6.0", + }, + { + emoji: "🇭🇹", + aliases: ["haiti"], + tags: [], + category: "Flags", + description: "flag: Haiti", + unicode_version: "6.0", + }, + { + emoji: "🇭🇺", + aliases: ["hungary"], + tags: [], + category: "Flags", + description: "flag: Hungary", + unicode_version: "6.0", + }, + { + emoji: "🇮🇨", + aliases: ["canary_islands"], + tags: [], + category: "Flags", + description: "flag: Canary Islands", + unicode_version: "6.0", + }, + { + emoji: "🇮🇩", + aliases: ["indonesia"], + tags: [], + category: "Flags", + description: "flag: Indonesia", + unicode_version: "6.0", + }, + { + emoji: "🇮🇪", + aliases: ["ireland"], + tags: [], + category: "Flags", + description: "flag: Ireland", + unicode_version: "6.0", + }, + { + emoji: "🇮🇱", + aliases: ["israel"], + tags: [], + category: "Flags", + description: "flag: Israel", + unicode_version: "6.0", + }, + { + emoji: "🇮🇲", + aliases: ["isle_of_man"], + tags: [], + category: "Flags", + description: "flag: Isle of Man", + unicode_version: "6.0", + }, + { + emoji: "🇮🇳", + aliases: ["india"], + tags: [], + category: "Flags", + description: "flag: India", + unicode_version: "6.0", + }, + { + emoji: "🇮🇴", + aliases: ["british_indian_ocean_territory"], + tags: [], + category: "Flags", + description: "flag: British Indian Ocean Territory", + unicode_version: "6.0", + }, + { + emoji: "🇮🇶", + aliases: ["iraq"], + tags: [], + category: "Flags", + description: "flag: Iraq", + unicode_version: "6.0", + }, + { + emoji: "🇮🇷", + aliases: ["iran"], + tags: [], + category: "Flags", + description: "flag: Iran", + unicode_version: "6.0", + }, + { + emoji: "🇮🇸", + aliases: ["iceland"], + tags: [], + category: "Flags", + description: "flag: Iceland", + unicode_version: "6.0", + }, + { + emoji: "🇮🇹", + aliases: ["it"], + tags: ["italy"], + category: "Flags", + description: "flag: Italy", + unicode_version: "6.0", + }, + { + emoji: "🇯🇪", + aliases: ["jersey"], + tags: [], + category: "Flags", + description: "flag: Jersey", + unicode_version: "6.0", + }, + { + emoji: "🇯🇲", + aliases: ["jamaica"], + tags: [], + category: "Flags", + description: "flag: Jamaica", + unicode_version: "6.0", + }, + { + emoji: "🇯🇴", + aliases: ["jordan"], + tags: [], + category: "Flags", + description: "flag: Jordan", + unicode_version: "6.0", + }, + { + emoji: "🇯🇵", + aliases: ["jp"], + tags: ["japan"], + category: "Flags", + description: "flag: Japan", + unicode_version: "6.0", + }, + { + emoji: "🇰🇪", + aliases: ["kenya"], + tags: [], + category: "Flags", + description: "flag: Kenya", + unicode_version: "6.0", + }, + { + emoji: "🇰🇬", + aliases: ["kyrgyzstan"], + tags: [], + category: "Flags", + description: "flag: Kyrgyzstan", + unicode_version: "6.0", + }, + { + emoji: "🇰🇭", + aliases: ["cambodia"], + tags: [], + category: "Flags", + description: "flag: Cambodia", + unicode_version: "6.0", + }, + { + emoji: "🇰🇮", + aliases: ["kiribati"], + tags: [], + category: "Flags", + description: "flag: Kiribati", + unicode_version: "6.0", + }, + { + emoji: "🇰🇲", + aliases: ["comoros"], + tags: [], + category: "Flags", + description: "flag: Comoros", + unicode_version: "6.0", + }, + { + emoji: "🇰🇳", + aliases: ["st_kitts_nevis"], + tags: [], + category: "Flags", + description: "flag: St. Kitts & Nevis", + unicode_version: "6.0", + }, + { + emoji: "🇰🇵", + aliases: ["north_korea"], + tags: [], + category: "Flags", + description: "flag: North Korea", + unicode_version: "6.0", + }, + { + emoji: "🇰🇷", + aliases: ["kr"], + tags: ["korea"], + category: "Flags", + description: "flag: South Korea", + unicode_version: "6.0", + }, + { + emoji: "🇰🇼", + aliases: ["kuwait"], + tags: [], + category: "Flags", + description: "flag: Kuwait", + unicode_version: "6.0", + }, + { + emoji: "🇰🇾", + aliases: ["cayman_islands"], + tags: [], + category: "Flags", + description: "flag: Cayman Islands", + unicode_version: "6.0", + }, + { + emoji: "🇰🇿", + aliases: ["kazakhstan"], + tags: [], + category: "Flags", + description: "flag: Kazakhstan", + unicode_version: "6.0", + }, + { + emoji: "🇱🇦", + aliases: ["laos"], + tags: [], + category: "Flags", + description: "flag: Laos", + unicode_version: "6.0", + }, + { + emoji: "🇱🇧", + aliases: ["lebanon"], + tags: [], + category: "Flags", + description: "flag: Lebanon", + unicode_version: "6.0", + }, + { + emoji: "🇱🇨", + aliases: ["st_lucia"], + tags: [], + category: "Flags", + description: "flag: St. Lucia", + unicode_version: "6.0", + }, + { + emoji: "🇱🇮", + aliases: ["liechtenstein"], + tags: [], + category: "Flags", + description: "flag: Liechtenstein", + unicode_version: "6.0", + }, + { + emoji: "🇱🇰", + aliases: ["sri_lanka"], + tags: [], + category: "Flags", + description: "flag: Sri Lanka", + unicode_version: "6.0", + }, + { + emoji: "🇱🇷", + aliases: ["liberia"], + tags: [], + category: "Flags", + description: "flag: Liberia", + unicode_version: "6.0", + }, + { + emoji: "🇱🇸", + aliases: ["lesotho"], + tags: [], + category: "Flags", + description: "flag: Lesotho", + unicode_version: "6.0", + }, + { + emoji: "🇱🇹", + aliases: ["lithuania"], + tags: [], + category: "Flags", + description: "flag: Lithuania", + unicode_version: "6.0", + }, + { + emoji: "🇱🇺", + aliases: ["luxembourg"], + tags: [], + category: "Flags", + description: "flag: Luxembourg", + unicode_version: "6.0", + }, + { + emoji: "🇱🇻", + aliases: ["latvia"], + tags: [], + category: "Flags", + description: "flag: Latvia", + unicode_version: "6.0", + }, + { + emoji: "🇱🇾", + aliases: ["libya"], + tags: [], + category: "Flags", + description: "flag: Libya", + unicode_version: "6.0", + }, + { + emoji: "🇲🇦", + aliases: ["morocco"], + tags: [], + category: "Flags", + description: "flag: Morocco", + unicode_version: "6.0", + }, + { + emoji: "🇲🇨", + aliases: ["monaco"], + tags: [], + category: "Flags", + description: "flag: Monaco", + unicode_version: "6.0", + }, + { + emoji: "🇲🇩", + aliases: ["moldova"], + tags: [], + category: "Flags", + description: "flag: Moldova", + unicode_version: "6.0", + }, + { + emoji: "🇲🇪", + aliases: ["montenegro"], + tags: [], + category: "Flags", + description: "flag: Montenegro", + unicode_version: "6.0", + }, + { + emoji: "🇲🇫", + aliases: ["st_martin"], + tags: [], + category: "Flags", + description: "flag: St. Martin", + unicode_version: "11.0", + }, + { + emoji: "🇲🇬", + aliases: ["madagascar"], + tags: [], + category: "Flags", + description: "flag: Madagascar", + unicode_version: "6.0", + }, + { + emoji: "🇲🇭", + aliases: ["marshall_islands"], + tags: [], + category: "Flags", + description: "flag: Marshall Islands", + unicode_version: "6.0", + }, + { + emoji: "🇲🇰", + aliases: ["macedonia"], + tags: [], + category: "Flags", + description: "flag: North Macedonia", + unicode_version: "6.0", + }, + { + emoji: "🇲🇱", + aliases: ["mali"], + tags: [], + category: "Flags", + description: "flag: Mali", + unicode_version: "6.0", + }, + { + emoji: "🇲🇲", + aliases: ["myanmar"], + tags: ["burma"], + category: "Flags", + description: "flag: Myanmar (Burma)", + unicode_version: "6.0", + }, + { + emoji: "🇲🇳", + aliases: ["mongolia"], + tags: [], + category: "Flags", + description: "flag: Mongolia", + unicode_version: "6.0", + }, + { + emoji: "🇲🇴", + aliases: ["macau"], + tags: [], + category: "Flags", + description: "flag: Macao SAR China", + unicode_version: "6.0", + }, + { + emoji: "🇲🇵", + aliases: ["northern_mariana_islands"], + tags: [], + category: "Flags", + description: "flag: Northern Mariana Islands", + unicode_version: "6.0", + }, + { + emoji: "🇲🇶", + aliases: ["martinique"], + tags: [], + category: "Flags", + description: "flag: Martinique", + unicode_version: "6.0", + }, + { + emoji: "🇲🇷", + aliases: ["mauritania"], + tags: [], + category: "Flags", + description: "flag: Mauritania", + unicode_version: "6.0", + }, + { + emoji: "🇲🇸", + aliases: ["montserrat"], + tags: [], + category: "Flags", + description: "flag: Montserrat", + unicode_version: "6.0", + }, + { + emoji: "🇲🇹", + aliases: ["malta"], + tags: [], + category: "Flags", + description: "flag: Malta", + unicode_version: "6.0", + }, + { + emoji: "🇲🇺", + aliases: ["mauritius"], + tags: [], + category: "Flags", + description: "flag: Mauritius", + unicode_version: "6.0", + }, + { + emoji: "🇲🇻", + aliases: ["maldives"], + tags: [], + category: "Flags", + description: "flag: Maldives", + unicode_version: "6.0", + }, + { + emoji: "🇲🇼", + aliases: ["malawi"], + tags: [], + category: "Flags", + description: "flag: Malawi", + unicode_version: "6.0", + }, + { + emoji: "🇲🇽", + aliases: ["mexico"], + tags: [], + category: "Flags", + description: "flag: Mexico", + unicode_version: "6.0", + }, + { + emoji: "🇲🇾", + aliases: ["malaysia"], + tags: [], + category: "Flags", + description: "flag: Malaysia", + unicode_version: "6.0", + }, + { + emoji: "🇲🇿", + aliases: ["mozambique"], + tags: [], + category: "Flags", + description: "flag: Mozambique", + unicode_version: "6.0", + }, + { + emoji: "🇳🇦", + aliases: ["namibia"], + tags: [], + category: "Flags", + description: "flag: Namibia", + unicode_version: "6.0", + }, + { + emoji: "🇳🇨", + aliases: ["new_caledonia"], + tags: [], + category: "Flags", + description: "flag: New Caledonia", + unicode_version: "6.0", + }, + { + emoji: "🇳🇪", + aliases: ["niger"], + tags: [], + category: "Flags", + description: "flag: Niger", + unicode_version: "6.0", + }, + { + emoji: "🇳🇫", + aliases: ["norfolk_island"], + tags: [], + category: "Flags", + description: "flag: Norfolk Island", + unicode_version: "6.0", + }, + { + emoji: "🇳🇬", + aliases: ["nigeria"], + tags: [], + category: "Flags", + description: "flag: Nigeria", + unicode_version: "6.0", + }, + { + emoji: "🇳🇮", + aliases: ["nicaragua"], + tags: [], + category: "Flags", + description: "flag: Nicaragua", + unicode_version: "6.0", + }, + { + emoji: "🇳🇱", + aliases: ["netherlands"], + tags: [], + category: "Flags", + description: "flag: Netherlands", + unicode_version: "6.0", + }, + { + emoji: "🇳🇴", + aliases: ["norway"], + tags: [], + category: "Flags", + description: "flag: Norway", + unicode_version: "6.0", + }, + { + emoji: "🇳🇵", + aliases: ["nepal"], + tags: [], + category: "Flags", + description: "flag: Nepal", + unicode_version: "6.0", + }, + { + emoji: "🇳🇷", + aliases: ["nauru"], + tags: [], + category: "Flags", + description: "flag: Nauru", + unicode_version: "6.0", + }, + { + emoji: "🇳🇺", + aliases: ["niue"], + tags: [], + category: "Flags", + description: "flag: Niue", + unicode_version: "6.0", + }, + { + emoji: "🇳🇿", + aliases: ["new_zealand"], + tags: [], + category: "Flags", + description: "flag: New Zealand", + unicode_version: "6.0", + }, + { + emoji: "🇴🇲", + aliases: ["oman"], + tags: [], + category: "Flags", + description: "flag: Oman", + unicode_version: "6.0", + }, + { + emoji: "🇵🇦", + aliases: ["panama"], + tags: [], + category: "Flags", + description: "flag: Panama", + unicode_version: "6.0", + }, + { + emoji: "🇵🇪", + aliases: ["peru"], + tags: [], + category: "Flags", + description: "flag: Peru", + unicode_version: "6.0", + }, + { + emoji: "🇵🇫", + aliases: ["french_polynesia"], + tags: [], + category: "Flags", + description: "flag: French Polynesia", + unicode_version: "6.0", + }, + { + emoji: "🇵🇬", + aliases: ["papua_new_guinea"], + tags: [], + category: "Flags", + description: "flag: Papua New Guinea", + unicode_version: "6.0", + }, + { + emoji: "🇵🇭", + aliases: ["philippines"], + tags: [], + category: "Flags", + description: "flag: Philippines", + unicode_version: "6.0", + }, + { + emoji: "🇵🇰", + aliases: ["pakistan"], + tags: [], + category: "Flags", + description: "flag: Pakistan", + unicode_version: "6.0", + }, + { + emoji: "🇵🇱", + aliases: ["poland"], + tags: [], + category: "Flags", + description: "flag: Poland", + unicode_version: "6.0", + }, + { + emoji: "🇵🇲", + aliases: ["st_pierre_miquelon"], + tags: [], + category: "Flags", + description: "flag: St. Pierre & Miquelon", + unicode_version: "6.0", + }, + { + emoji: "🇵🇳", + aliases: ["pitcairn_islands"], + tags: [], + category: "Flags", + description: "flag: Pitcairn Islands", + unicode_version: "6.0", + }, + { + emoji: "🇵🇷", + aliases: ["puerto_rico"], + tags: [], + category: "Flags", + description: "flag: Puerto Rico", + unicode_version: "6.0", + }, + { + emoji: "🇵🇸", + aliases: ["palestinian_territories"], + tags: [], + category: "Flags", + description: "flag: Palestinian Territories", + unicode_version: "6.0", + }, + { + emoji: "🇵🇹", + aliases: ["portugal"], + tags: [], + category: "Flags", + description: "flag: Portugal", + unicode_version: "6.0", + }, + { + emoji: "🇵🇼", + aliases: ["palau"], + tags: [], + category: "Flags", + description: "flag: Palau", + unicode_version: "6.0", + }, + { + emoji: "🇵🇾", + aliases: ["paraguay"], + tags: [], + category: "Flags", + description: "flag: Paraguay", + unicode_version: "6.0", + }, + { + emoji: "🇶🇦", + aliases: ["qatar"], + tags: [], + category: "Flags", + description: "flag: Qatar", + unicode_version: "6.0", + }, + { + emoji: "🇷🇪", + aliases: ["reunion"], + tags: [], + category: "Flags", + description: "flag: Réunion", + unicode_version: "6.0", + }, + { + emoji: "🇷🇴", + aliases: ["romania"], + tags: [], + category: "Flags", + description: "flag: Romania", + unicode_version: "6.0", + }, + { + emoji: "🇷🇸", + aliases: ["serbia"], + tags: [], + category: "Flags", + description: "flag: Serbia", + unicode_version: "6.0", + }, + { + emoji: "🇷🇺", + aliases: ["ru"], + tags: ["russia"], + category: "Flags", + description: "flag: Russia", + unicode_version: "6.0", + }, + { + emoji: "🇷🇼", + aliases: ["rwanda"], + tags: [], + category: "Flags", + description: "flag: Rwanda", + unicode_version: "6.0", + }, + { + emoji: "🇸🇦", + aliases: ["saudi_arabia"], + tags: [], + category: "Flags", + description: "flag: Saudi Arabia", + unicode_version: "6.0", + }, + { + emoji: "🇸🇧", + aliases: ["solomon_islands"], + tags: [], + category: "Flags", + description: "flag: Solomon Islands", + unicode_version: "6.0", + }, + { + emoji: "🇸🇨", + aliases: ["seychelles"], + tags: [], + category: "Flags", + description: "flag: Seychelles", + unicode_version: "6.0", + }, + { + emoji: "🇸🇩", + aliases: ["sudan"], + tags: [], + category: "Flags", + description: "flag: Sudan", + unicode_version: "6.0", + }, + { + emoji: "🇸🇪", + aliases: ["sweden"], + tags: [], + category: "Flags", + description: "flag: Sweden", + unicode_version: "6.0", + }, + { + emoji: "🇸🇬", + aliases: ["singapore"], + tags: [], + category: "Flags", + description: "flag: Singapore", + unicode_version: "6.0", + }, + { + emoji: "🇸🇭", + aliases: ["st_helena"], + tags: [], + category: "Flags", + description: "flag: St. Helena", + unicode_version: "6.0", + }, + { + emoji: "🇸🇮", + aliases: ["slovenia"], + tags: [], + category: "Flags", + description: "flag: Slovenia", + unicode_version: "6.0", + }, + { + emoji: "🇸🇯", + aliases: ["svalbard_jan_mayen"], + tags: [], + category: "Flags", + description: "flag: Svalbard & Jan Mayen", + unicode_version: "11.0", + }, + { + emoji: "🇸🇰", + aliases: ["slovakia"], + tags: [], + category: "Flags", + description: "flag: Slovakia", + unicode_version: "6.0", + }, + { + emoji: "🇸🇱", + aliases: ["sierra_leone"], + tags: [], + category: "Flags", + description: "flag: Sierra Leone", + unicode_version: "6.0", + }, + { + emoji: "🇸🇲", + aliases: ["san_marino"], + tags: [], + category: "Flags", + description: "flag: San Marino", + unicode_version: "6.0", + }, + { + emoji: "🇸🇳", + aliases: ["senegal"], + tags: [], + category: "Flags", + description: "flag: Senegal", + unicode_version: "6.0", + }, + { + emoji: "🇸🇴", + aliases: ["somalia"], + tags: [], + category: "Flags", + description: "flag: Somalia", + unicode_version: "6.0", + }, + { + emoji: "🇸🇷", + aliases: ["suriname"], + tags: [], + category: "Flags", + description: "flag: Suriname", + unicode_version: "6.0", + }, + { + emoji: "🇸🇸", + aliases: ["south_sudan"], + tags: [], + category: "Flags", + description: "flag: South Sudan", + unicode_version: "6.0", + }, + { + emoji: "🇸🇹", + aliases: ["sao_tome_principe"], + tags: [], + category: "Flags", + description: "flag: São Tomé & Príncipe", + unicode_version: "6.0", + }, + { + emoji: "🇸🇻", + aliases: ["el_salvador"], + tags: [], + category: "Flags", + description: "flag: El Salvador", + unicode_version: "6.0", + }, + { + emoji: "🇸🇽", + aliases: ["sint_maarten"], + tags: [], + category: "Flags", + description: "flag: Sint Maarten", + unicode_version: "6.0", + }, + { + emoji: "🇸🇾", + aliases: ["syria"], + tags: [], + category: "Flags", + description: "flag: Syria", + unicode_version: "6.0", + }, + { + emoji: "🇸🇿", + aliases: ["swaziland"], + tags: [], + category: "Flags", + description: "flag: Eswatini", + unicode_version: "6.0", + }, + { + emoji: "🇹🇦", + aliases: ["tristan_da_cunha"], + tags: [], + category: "Flags", + description: "flag: Tristan da Cunha", + unicode_version: "11.0", + }, + { + emoji: "🇹🇨", + aliases: ["turks_caicos_islands"], + tags: [], + category: "Flags", + description: "flag: Turks & Caicos Islands", + unicode_version: "6.0", + }, + { + emoji: "🇹🇩", + aliases: ["chad"], + tags: [], + category: "Flags", + description: "flag: Chad", + unicode_version: "6.0", + }, + { + emoji: "🇹🇫", + aliases: ["french_southern_territories"], + tags: [], + category: "Flags", + description: "flag: French Southern Territories", + unicode_version: "6.0", + }, + { + emoji: "🇹🇬", + aliases: ["togo"], + tags: [], + category: "Flags", + description: "flag: Togo", + unicode_version: "6.0", + }, + { + emoji: "🇹🇭", + aliases: ["thailand"], + tags: [], + category: "Flags", + description: "flag: Thailand", + unicode_version: "6.0", + }, + { + emoji: "🇹🇯", + aliases: ["tajikistan"], + tags: [], + category: "Flags", + description: "flag: Tajikistan", + unicode_version: "6.0", + }, + { + emoji: "🇹🇰", + aliases: ["tokelau"], + tags: [], + category: "Flags", + description: "flag: Tokelau", + unicode_version: "6.0", + }, + { + emoji: "🇹🇱", + aliases: ["timor_leste"], + tags: [], + category: "Flags", + description: "flag: Timor-Leste", + unicode_version: "6.0", + }, + { + emoji: "🇹🇲", + aliases: ["turkmenistan"], + tags: [], + category: "Flags", + description: "flag: Turkmenistan", + unicode_version: "6.0", + }, + { + emoji: "🇹🇳", + aliases: ["tunisia"], + tags: [], + category: "Flags", + description: "flag: Tunisia", + unicode_version: "6.0", + }, + { + emoji: "🇹🇴", + aliases: ["tonga"], + tags: [], + category: "Flags", + description: "flag: Tonga", + unicode_version: "6.0", + }, + { + emoji: "🇹🇷", + aliases: ["tr"], + tags: ["turkey"], + category: "Flags", + description: "flag: Turkey", + unicode_version: "8.0", + }, + { + emoji: "🇹🇹", + aliases: ["trinidad_tobago"], + tags: [], + category: "Flags", + description: "flag: Trinidad & Tobago", + unicode_version: "6.0", + }, + { + emoji: "🇹🇻", + aliases: ["tuvalu"], + tags: [], + category: "Flags", + description: "flag: Tuvalu", + unicode_version: "6.0", + }, + { + emoji: "🇹🇼", + aliases: ["taiwan"], + tags: [], + category: "Flags", + description: "flag: Taiwan", + unicode_version: "6.0", + }, + { + emoji: "🇹🇿", + aliases: ["tanzania"], + tags: [], + category: "Flags", + description: "flag: Tanzania", + unicode_version: "6.0", + }, + { + emoji: "🇺🇦", + aliases: ["ukraine"], + tags: [], + category: "Flags", + description: "flag: Ukraine", + unicode_version: "6.0", + }, + { + emoji: "🇺🇬", + aliases: ["uganda"], + tags: [], + category: "Flags", + description: "flag: Uganda", + unicode_version: "6.0", + }, + { + emoji: "🇺🇲", + aliases: ["us_outlying_islands"], + tags: [], + category: "Flags", + description: "flag: U.S. Outlying Islands", + unicode_version: "11.0", + }, + { + emoji: "🇺🇳", + aliases: ["united_nations"], + tags: [], + category: "Flags", + description: "flag: United Nations", + unicode_version: "11.0", + }, + { + emoji: "🇺🇸", + aliases: ["us"], + tags: ["flag", "united", "america"], + category: "Flags", + description: "flag: United States", + unicode_version: "6.0", + }, + { + emoji: "🇺🇾", + aliases: ["uruguay"], + tags: [], + category: "Flags", + description: "flag: Uruguay", + unicode_version: "6.0", + }, + { + emoji: "🇺🇿", + aliases: ["uzbekistan"], + tags: [], + category: "Flags", + description: "flag: Uzbekistan", + unicode_version: "6.0", + }, + { + emoji: "🇻🇦", + aliases: ["vatican_city"], + tags: [], + category: "Flags", + description: "flag: Vatican City", + unicode_version: "6.0", + }, + { + emoji: "🇻🇨", + aliases: ["st_vincent_grenadines"], + tags: [], + category: "Flags", + description: "flag: St. Vincent & Grenadines", + unicode_version: "6.0", + }, + { + emoji: "🇻🇪", + aliases: ["venezuela"], + tags: [], + category: "Flags", + description: "flag: Venezuela", + unicode_version: "6.0", + }, + { + emoji: "🇻🇬", + aliases: ["british_virgin_islands"], + tags: [], + category: "Flags", + description: "flag: British Virgin Islands", + unicode_version: "6.0", + }, + { + emoji: "🇻🇮", + aliases: ["us_virgin_islands"], + tags: [], + category: "Flags", + description: "flag: U.S. Virgin Islands", + unicode_version: "6.0", + }, + { + emoji: "🇻🇳", + aliases: ["vietnam"], + tags: [], + category: "Flags", + description: "flag: Vietnam", + unicode_version: "6.0", + }, + { + emoji: "🇻🇺", + aliases: ["vanuatu"], + tags: [], + category: "Flags", + description: "flag: Vanuatu", + unicode_version: "6.0", + }, + { + emoji: "🇼🇫", + aliases: ["wallis_futuna"], + tags: [], + category: "Flags", + description: "flag: Wallis & Futuna", + unicode_version: "6.0", + }, + { + emoji: "🇼🇸", + aliases: ["samoa"], + tags: [], + category: "Flags", + description: "flag: Samoa", + unicode_version: "6.0", + }, + { + emoji: "🇽🇰", + aliases: ["kosovo"], + tags: [], + category: "Flags", + description: "flag: Kosovo", + unicode_version: "6.0", + }, + { + emoji: "🇾🇪", + aliases: ["yemen"], + tags: [], + category: "Flags", + description: "flag: Yemen", + unicode_version: "6.0", + }, + { + emoji: "🇾🇹", + aliases: ["mayotte"], + tags: [], + category: "Flags", + description: "flag: Mayotte", + unicode_version: "6.0", + }, + { + emoji: "🇿🇦", + aliases: ["south_africa"], + tags: [], + category: "Flags", + description: "flag: South Africa", + unicode_version: "6.0", + }, + { + emoji: "🇿🇲", + aliases: ["zambia"], + tags: [], + category: "Flags", + description: "flag: Zambia", + unicode_version: "6.0", + }, + { + emoji: "🇿🇼", + aliases: ["zimbabwe"], + tags: [], + category: "Flags", + description: "flag: Zimbabwe", + unicode_version: "6.0", + }, + { + emoji: "🏴󠁧󠁢󠁥󠁮󠁧󠁿", + aliases: ["england"], + tags: [], + category: "Flags", + description: "flag: England", + unicode_version: "11.0", + }, + { + emoji: "🏴󠁧󠁢󠁳󠁣󠁴󠁿", + aliases: ["scotland"], + tags: [], + category: "Flags", + description: "flag: Scotland", + unicode_version: "11.0", + }, + { + emoji: "🏴󠁧󠁢󠁷󠁬󠁳󠁿", + aliases: ["wales"], + tags: [], + category: "Flags", + description: "flag: Wales", + unicode_version: "11.0", + }, +]; diff --git a/web/src/app/errors.js b/web/src/app/errors.js index 38165a2..96aaf86 100644 --- a/web/src/app/errors.js +++ b/web/src/app/errors.js @@ -1,66 +1,80 @@ // This is a subset of, and the counterpart to errors.go export const fetchOrThrow = async (url, options) => { - const response = await fetch(url, options); - if (response.status !== 200) { - await throwAppError(response); - } - return response; // Promise! + const response = await fetch(url, options); + if (response.status !== 200) { + await throwAppError(response); + } + return response; // Promise! }; export const throwAppError = async (response) => { - if (response.status === 401 || response.status === 403) { - console.log(`[Error] HTTP ${response.status}`, response); - throw new UnauthorizedError(); + if (response.status === 401 || response.status === 403) { + console.log(`[Error] HTTP ${response.status}`, response); + throw new UnauthorizedError(); + } + const error = await maybeToJson(response); + if (error?.code) { + console.log( + `[Error] HTTP ${response.status}, ntfy error ${error.code}: ${ + error.error || "" + }`, + response + ); + if (error.code === UserExistsError.CODE) { + throw new UserExistsError(); + } else if (error.code === TopicReservedError.CODE) { + throw new TopicReservedError(); + } else if (error.code === AccountCreateLimitReachedError.CODE) { + throw new AccountCreateLimitReachedError(); + } else if (error.code === IncorrectPasswordError.CODE) { + throw new IncorrectPasswordError(); + } else if (error?.error) { + throw new Error(`Error ${error.code}: ${error.error}`); } - const error = await maybeToJson(response); - if (error?.code) { - console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response); - if (error.code === UserExistsError.CODE) { - throw new UserExistsError(); - } else if (error.code === TopicReservedError.CODE) { - throw new TopicReservedError(); - } else if (error.code === AccountCreateLimitReachedError.CODE) { - throw new AccountCreateLimitReachedError(); - } else if (error.code === IncorrectPasswordError.CODE) { - throw new IncorrectPasswordError(); - } else if (error?.error) { - throw new Error(`Error ${error.code}: ${error.error}`); - } - } - console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response); - throw new Error(`Unexpected response ${response.status}`); + } + console.log(`[Error] HTTP ${response.status}, not a ntfy error`, response); + throw new Error(`Unexpected response ${response.status}`); }; const maybeToJson = async (response) => { - try { - return await response.json(); - } catch (e) { - return null; - } -} + try { + return await response.json(); + } catch (e) { + return null; + } +}; export class UnauthorizedError extends Error { - constructor() { super("Unauthorized"); } + constructor() { + super("Unauthorized"); + } } export class UserExistsError extends Error { - static CODE = 40901; // errHTTPConflictUserExists - constructor() { super("Username already exists"); } + static CODE = 40901; // errHTTPConflictUserExists + constructor() { + super("Username already exists"); + } } export class TopicReservedError extends Error { - static CODE = 40902; // errHTTPConflictTopicReserved - constructor() { super("Topic already reserved"); } + static CODE = 40902; // errHTTPConflictTopicReserved + constructor() { + super("Topic already reserved"); + } } export class AccountCreateLimitReachedError extends Error { - static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation - constructor() { super("Account creation limit reached"); } + static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation + constructor() { + super("Account creation limit reached"); + } } export class IncorrectPasswordError extends Error { - static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation - constructor() { super("Password incorrect"); } + static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation + constructor() { + super("Password incorrect"); + } } - diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 346df37..f67c2d4 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -1,4 +1,4 @@ -import {rawEmojis} from "./emojis"; +import { rawEmojis } from "./emojis"; import beep from "../sounds/beep.mp3"; import juntos from "../sounds/juntos.mp3"; import pristine from "../sounds/pristine.mp3"; @@ -7,300 +7,316 @@ import dadum from "../sounds/dadum.mp3"; import pop from "../sounds/pop.mp3"; import popSwoosh from "../sounds/pop-swoosh.mp3"; import config from "./config"; -import {Base64} from 'js-base64'; +import { Base64 } from "js-base64"; export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`; -export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws` +export const topicUrlWs = (baseUrl, topic) => + `${topicUrl(baseUrl, topic)}/ws` .replaceAll("https://", "wss://") .replaceAll("http://", "ws://"); -export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`; -export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`; -export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`; -export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; -export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); +export const topicUrlJson = (baseUrl, topic) => + `${topicUrl(baseUrl, topic)}/json`; +export const topicUrlJsonPoll = (baseUrl, topic) => + `${topicUrlJson(baseUrl, topic)}?poll=1`; +export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => + `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`; +export const topicUrlAuth = (baseUrl, topic) => + `${topicUrl(baseUrl, topic)}/auth`; +export const topicShortUrl = (baseUrl, topic) => + shortUrl(topicUrl(baseUrl, topic)); export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`; export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`; export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`; export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`; -export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`; -export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reservation`; -export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`; -export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; -export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; +export const accountSubscriptionUrl = (baseUrl) => + `${baseUrl}/v1/account/subscription`; +export const accountReservationUrl = (baseUrl) => + `${baseUrl}/v1/account/reservation`; +export const accountReservationSingleUrl = (baseUrl, topic) => + `${baseUrl}/v1/account/reservation/${topic}`; +export const accountBillingSubscriptionUrl = (baseUrl) => + `${baseUrl}/v1/account/billing/subscription`; +export const accountBillingPortalUrl = (baseUrl) => + `${baseUrl}/v1/account/billing/portal`; export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; -export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; +export const accountPhoneVerifyUrl = (baseUrl) => + `${baseUrl}/v1/account/phone/verify`; export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; export const expandSecureUrl = (url) => `https://${url}`; export const validUrl = (url) => { - return url.match(/^https?:\/\/.+/); -} + return url.match(/^https?:\/\/.+/); +}; export const validTopic = (topic) => { - if (disallowedTopic(topic)) { - return false; - } - return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app! -} + if (disallowedTopic(topic)) { + return false; + } + return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app! +}; export const disallowedTopic = (topic) => { - return config.disallowed_topics.includes(topic); -} + return config.disallowed_topics.includes(topic); +}; export const topicDisplayName = (subscription) => { - if (subscription.displayName) { - return subscription.displayName; - } else if (subscription.baseUrl === config.base_url) { - return subscription.topic; - } - return topicShortUrl(subscription.baseUrl, subscription.topic); + if (subscription.displayName) { + return subscription.displayName; + } else if (subscription.baseUrl === config.base_url) { + return subscription.topic; + } + return topicShortUrl(subscription.baseUrl, subscription.topic); }; // Format emojis (see emoji.js) const emojis = {}; -rawEmojis.forEach(emoji => { - emoji.aliases.forEach(alias => { - emojis[alias] = emoji.emoji; - }); +rawEmojis.forEach((emoji) => { + emoji.aliases.forEach((alias) => { + emojis[alias] = emoji.emoji; + }); }); const toEmojis = (tags) => { - if (!tags) return []; - else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]); -} + if (!tags) return []; + else return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]); +}; export const formatTitleWithDefault = (m, fallback) => { - if (m.title) { - return formatTitle(m); - } - return fallback; + if (m.title) { + return formatTitle(m); + } + return fallback; }; export const formatTitle = (m) => { - const emojiList = toEmojis(m.tags); - if (emojiList.length > 0) { - return `${emojiList.join(" ")} ${m.title}`; - } else { - return m.title; - } + const emojiList = toEmojis(m.tags); + if (emojiList.length > 0) { + return `${emojiList.join(" ")} ${m.title}`; + } else { + return m.title; + } }; export const formatMessage = (m) => { - if (m.title) { - return m.message; + if (m.title) { + return m.message; + } else { + const emojiList = toEmojis(m.tags); + if (emojiList.length > 0) { + return `${emojiList.join(" ")} ${m.message}`; } else { - const emojiList = toEmojis(m.tags); - if (emojiList.length > 0) { - return `${emojiList.join(" ")} ${m.message}`; - } else { - return m.message; - } + return m.message; } + } }; export const unmatchedTags = (tags) => { - if (!tags) return []; - else return tags.filter(tag => !(tag in emojis)); -} + if (!tags) return []; + else return tags.filter((tag) => !(tag in emojis)); +}; export const maybeWithAuth = (headers, user) => { - if (user && user.password) { - return withBasicAuth(headers, user.username, user.password); - } else if (user && user.token) { - return withBearerAuth(headers, user.token); - } - return headers; -} + if (user && user.password) { + return withBasicAuth(headers, user.username, user.password); + } else if (user && user.token) { + return withBearerAuth(headers, user.token); + } + return headers; +}; export const maybeWithBearerAuth = (headers, token) => { - if (token) { - return withBearerAuth(headers, token); - } - return headers; -} + if (token) { + return withBearerAuth(headers, token); + } + return headers; +}; export const withBasicAuth = (headers, username, password) => { - headers['Authorization'] = basicAuth(username, password); - return headers; -} + headers["Authorization"] = basicAuth(username, password); + return headers; +}; export const basicAuth = (username, password) => { - return `Basic ${encodeBase64(`${username}:${password}`)}`; -} + return `Basic ${encodeBase64(`${username}:${password}`)}`; +}; export const withBearerAuth = (headers, token) => { - headers['Authorization'] = bearerAuth(token); - return headers; -} + headers["Authorization"] = bearerAuth(token); + return headers; +}; export const bearerAuth = (token) => { - return `Bearer ${token}`; -} + return `Bearer ${token}`; +}; export const encodeBase64 = (s) => { - return Base64.encode(s); -} + return Base64.encode(s); +}; export const encodeBase64Url = (s) => { - return Base64.encodeURI(s); -} + return Base64.encodeURI(s); +}; export const maybeAppendActionErrors = (message, notification) => { - const actionErrors = (notification.actions ?? []) - .map(action => action.error) - .filter(action => !!action) - .join("\n") - if (actionErrors.length === 0) { - return message; - } else { - return `${message}\n\n${actionErrors}`; - } -} + const actionErrors = (notification.actions ?? []) + .map((action) => action.error) + .filter((action) => !!action) + .join("\n"); + if (actionErrors.length === 0) { + return message; + } else { + return `${message}\n\n${actionErrors}`; + } +}; export const shuffle = (arr) => { - let j, x; - for (let index = arr.length - 1; index > 0; index--) { - j = Math.floor(Math.random() * (index + 1)); - x = arr[index]; - arr[index] = arr[j]; - arr[j] = x; - } - return arr; -} + let j, x; + for (let index = arr.length - 1; index > 0; index--) { + j = Math.floor(Math.random() * (index + 1)); + x = arr[index]; + arr[index] = arr[j]; + arr[j] = x; + } + return arr; +}; export const splitNoEmpty = (s, delimiter) => { - return s - .split(delimiter) - .map(x => x.trim()) - .filter(x => x !== ""); -} + return s + .split(delimiter) + .map((x) => x.trim()) + .filter((x) => x !== ""); +}; /** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */ export const hashCode = async (s) => { - let hash = 0; - for (let i = 0; i < s.length; i++) { - const char = s.charCodeAt(i); - hash = ((hash<<5)-hash)+char; - hash = hash & hash; // Convert to 32bit integer - } - return hash; -} + let hash = 0; + for (let i = 0; i < s.length; i++) { + const char = s.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return hash; +}; export const formatShortDateTime = (timestamp) => { - return new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'}) - .format(new Date(timestamp * 1000)); -} + return new Intl.DateTimeFormat("default", { + dateStyle: "short", + timeStyle: "short", + }).format(new Date(timestamp * 1000)); +}; export const formatShortDate = (timestamp) => { - return new Intl.DateTimeFormat('default', {dateStyle: 'short'}) - .format(new Date(timestamp * 1000)); -} + return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format( + new Date(timestamp * 1000) + ); +}; export const formatBytes = (bytes, decimals = 2) => { - if (bytes === 0) return '0 bytes'; - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; -} + if (bytes === 0) return "0 bytes"; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; +}; export const formatNumber = (n) => { - if (n === 0) { - return n; - } else if (n % 1000 === 0) { - return `${n/1000}k`; - } - return n.toLocaleString(); -} + if (n === 0) { + return n; + } else if (n % 1000 === 0) { + return `${n / 1000}k`; + } + return n.toLocaleString(); +}; export const formatPrice = (n) => { - if (n % 100 === 0) { - return `$${n/100}`; - } - return `$${(n/100).toPrecision(2)}`; -} + if (n % 100 === 0) { + return `$${n / 100}`; + } + return `$${(n / 100).toPrecision(2)}`; +}; export const openUrl = (url) => { - window.open(url, "_blank", "noopener,noreferrer"); + window.open(url, "_blank", "noopener,noreferrer"); }; export const sounds = { - "ding": { - file: ding, - label: "Ding" - }, - "juntos": { - file: juntos, - label: "Juntos" - }, - "pristine": { - file: pristine, - label: "Pristine" - }, - "dadum": { - file: dadum, - label: "Dadum" - }, - "pop": { - file: pop, - label: "Pop" - }, - "pop-swoosh": { - file: popSwoosh, - label: "Pop swoosh" - }, - "beep": { - file: beep, - label: "Beep" - } + ding: { + file: ding, + label: "Ding", + }, + juntos: { + file: juntos, + label: "Juntos", + }, + pristine: { + file: pristine, + label: "Pristine", + }, + dadum: { + file: dadum, + label: "Dadum", + }, + pop: { + file: pop, + label: "Pop", + }, + "pop-swoosh": { + file: popSwoosh, + label: "Pop swoosh", + }, + beep: { + file: beep, + label: "Beep", + }, }; export const playSound = async (id) => { - const audio = new Audio(sounds[id].file); - return audio.play(); + const audio = new Audio(sounds[id].file); + return audio.play(); }; // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch export async function* fetchLinesIterator(fileURL, headers) { - const utf8Decoder = new TextDecoder('utf-8'); - const response = await fetch(fileURL, { - headers: headers - }); - const reader = response.body.getReader(); - let { value: chunk, done: readerDone } = await reader.read(); - chunk = chunk ? utf8Decoder.decode(chunk) : ''; + const utf8Decoder = new TextDecoder("utf-8"); + const response = await fetch(fileURL, { + headers: headers, + }); + const reader = response.body.getReader(); + let { value: chunk, done: readerDone } = await reader.read(); + chunk = chunk ? utf8Decoder.decode(chunk) : ""; - const re = /\n|\r|\r\n/gm; - let startIndex = 0; + const re = /\n|\r|\r\n/gm; + let startIndex = 0; - for (;;) { - let result = re.exec(chunk); - if (!result) { - if (readerDone) { - break; - } - let remainder = chunk.substr(startIndex); - ({ value: chunk, done: readerDone } = await reader.read()); - chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : ''); - startIndex = re.lastIndex = 0; - continue; - } - yield chunk.substring(startIndex, result.index); - startIndex = re.lastIndex; - } - if (startIndex < chunk.length) { - yield chunk.substr(startIndex); // last line didn't end in a newline char + for (;;) { + let result = re.exec(chunk); + if (!result) { + if (readerDone) { + break; + } + let remainder = chunk.substr(startIndex); + ({ value: chunk, done: readerDone } = await reader.read()); + chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : ""); + startIndex = re.lastIndex = 0; + continue; } + yield chunk.substring(startIndex, result.index); + startIndex = re.lastIndex; + } + if (startIndex < chunk.length) { + yield chunk.substr(startIndex); // last line didn't end in a newline char + } } export const randomAlphanumericString = (len) => { - const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let id = ""; - for (let i = 0; i < len; i++) { - id += alphabet[(Math.random() * alphabet.length) | 0]; - } - return id; -} + const alphabet = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let id = ""; + for (let i = 0; i < len; i++) { + id += alphabet[(Math.random() * alphabet.length) | 0]; + } + return id; +}; diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 710510d..bb8e7a7 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -1,36 +1,36 @@ -import * as React from 'react'; -import {useContext, useState} from 'react'; +import * as React from "react"; +import { useContext, useState } from "react"; import { - Alert, - CardActions, - CardContent, - Chip, - FormControl, - FormControlLabel, - LinearProgress, - Link, - Portal, - Radio, - RadioGroup, - Select, - Snackbar, - Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - useMediaQuery + Alert, + CardActions, + CardContent, + Chip, + FormControl, + FormControlLabel, + LinearProgress, + Link, + Portal, + Radio, + RadioGroup, + Select, + Snackbar, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + useMediaQuery, } from "@mui/material"; -import Tooltip from '@mui/material/Tooltip'; +import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; -import EditIcon from '@mui/icons-material/Edit'; +import EditIcon from "@mui/icons-material/Edit"; import Container from "@mui/material/Container"; import Card from "@mui/material/Card"; import Button from "@mui/material/Button"; -import {Trans, useTranslation} from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import session from "../app/Session"; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import theme from "./theme"; import Dialog from "@mui/material/Dialog"; import DialogTitle from "@mui/material/DialogTitle"; @@ -38,997 +38,1342 @@ import DialogContent from "@mui/material/DialogContent"; import TextField from "@mui/material/TextField"; import routes from "./routes"; import IconButton from "@mui/material/IconButton"; -import {formatBytes, formatShortDate, formatShortDateTime, openUrl} from "../app/utils"; -import accountApi, {LimitBasis, Role, SubscriptionInterval, SubscriptionStatus} from "../app/AccountApi"; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import {Pref, PrefGroup} from "./Pref"; +import { + formatBytes, + formatShortDate, + formatShortDateTime, + openUrl, +} from "../app/utils"; +import accountApi, { + LimitBasis, + Role, + SubscriptionInterval, + SubscriptionStatus, +} from "../app/AccountApi"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { Pref, PrefGroup } from "./Pref"; import db from "../app/db"; import i18n from "i18next"; import humanizeDuration from "humanize-duration"; import UpgradeDialog from "./UpgradeDialog"; import CelebrationIcon from "@mui/icons-material/Celebration"; -import {AccountContext} from "./App"; +import { AccountContext } from "./App"; import DialogFooter from "./DialogFooter"; -import {Paragraph} from "./styles"; +import { Paragraph } from "./styles"; import CloseIcon from "@mui/icons-material/Close"; -import {ContentCopy, Public} from "@mui/icons-material"; +import { ContentCopy, Public } from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; import DialogContentText from "@mui/material/DialogContentText"; -import {IncorrectPasswordError, UnauthorizedError} from "../app/errors"; -import {ProChip} from "./SubscriptionPopup"; +import { IncorrectPasswordError, UnauthorizedError } from "../app/errors"; +import { ProChip } from "./SubscriptionPopup"; import AddIcon from "@mui/icons-material/Add"; const Account = () => { - if (!session.exists()) { - window.location.href = routes.app; - return <>; - } - return ( - - - - - - - - - ); + if (!session.exists()) { + window.location.href = routes.app; + return <>; + } + return ( + + + + + + + + + ); }; const Basics = () => { - const { t } = useTranslation(); - return ( - - - {t("account_basics_title")} - - - - - - - - - ); + const { t } = useTranslation(); + return ( + + + {t("account_basics_title")} + + + + + + + + + ); }; const Username = () => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const labelId = "prefUsername"; + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const labelId = "prefUsername"; - return ( - -
- {session.username()} - {account?.role === Role.ADMIN - ? <>{" "}👑 - : ""} -
-
- ) + return ( + +
+ {session.username()} + {account?.role === Role.ADMIN ? ( + <> + {" "} + + 👑 + + + ) : ( + "" + )} +
+
+ ); }; const ChangePassword = () => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - const labelId = "prefChangePassword"; + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const labelId = "prefChangePassword"; - const handleDialogOpen = () => { - setDialogKey(prev => prev+1); - setDialogOpen(true); - }; + const handleDialogOpen = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; - const handleDialogClose = () => { - setDialogOpen(false); - }; + const handleDialogClose = () => { + setDialogOpen(false); + }; - return ( - -
- ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤ - - - -
- -
- ) + return ( + +
+ + ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤ + + + + +
+ +
+ ); }; const ChangePasswordDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const handleDialogSubmit = async () => { - try { - console.debug(`[Account] Changing password`); - await accountApi.changePassword(currentPassword, newPassword); - props.onClose(); - } catch (e) { - console.log(`[Account] Error changing password`, e); - if (e instanceof IncorrectPasswordError) { - setError(t("account_basics_password_dialog_current_password_incorrect")); - } else if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } - }; + const handleDialogSubmit = async () => { + try { + console.debug(`[Account] Changing password`); + await accountApi.changePassword(currentPassword, newPassword); + props.onClose(); + } catch (e) { + console.log(`[Account] Error changing password`, e); + if (e instanceof IncorrectPasswordError) { + setError( + t("account_basics_password_dialog_current_password_incorrect") + ); + } else if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } + }; - return ( - - {t("account_basics_password_dialog_title")} - - setCurrentPassword(ev.target.value)} - fullWidth - variant="standard" - /> - setNewPassword(ev.target.value)} - fullWidth - variant="standard" - /> - setConfirmPassword(ev.target.value)} - fullWidth - variant="standard" - /> - - - - - - - ); + return ( + + {t("account_basics_password_dialog_title")} + + setCurrentPassword(ev.target.value)} + fullWidth + variant="standard" + /> + setNewPassword(ev.target.value)} + fullWidth + variant="standard" + /> + setConfirmPassword(ev.target.value)} + fullWidth + variant="standard" + /> + + + + + + + ); }; const AccountType = () => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [upgradeDialogKey, setUpgradeDialogKey] = useState(0); - const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); - const [showPortalError, setShowPortalError] = useState(false); + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [upgradeDialogKey, setUpgradeDialogKey] = useState(0); + const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); + const [showPortalError, setShowPortalError] = useState(false); - if (!account) { - return <>; + if (!account) { + return <>; + } + + const handleUpgradeClick = () => { + setUpgradeDialogKey((k) => k + 1); + setUpgradeDialogOpen(true); + }; + + const handleManageBilling = async () => { + try { + const response = await accountApi.createBillingPortalSession(); + window.open(response.redirect_url, "billing_portal"); + } catch (e) { + console.log(`[Account] Error opening billing portal`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setShowPortalError(true); + } } + }; - const handleUpgradeClick = () => { - setUpgradeDialogKey(k => k + 1); - setUpgradeDialogOpen(true); + let accountType; + if (account.role === Role.ADMIN) { + const tierSuffix = account.tier + ? t("account_basics_tier_admin_suffix_with_tier", { + tier: account.tier.name, + }) + : t("account_basics_tier_admin_suffix_no_tier"); + accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`; + } else if (!account.tier) { + accountType = config.enable_payments + ? t("account_basics_tier_free") + : t("account_basics_tier_basic"); + } else { + accountType = account.tier.name; + if (account.billing?.interval === SubscriptionInterval.MONTH) { + accountType += ` (${t("account_basics_tier_interval_monthly")})`; + } else if (account.billing?.interval === SubscriptionInterval.YEAR) { + accountType += ` (${t("account_basics_tier_interval_yearly")})`; } + } - const handleManageBilling = async () => { - try { - const response = await accountApi.createBillingPortalSession(); - window.open(response.redirect_url, "billing_portal"); - } catch (e) { - console.log(`[Account] Error opening billing portal`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setShowPortalError(true); - } - } - }; - - let accountType; - if (account.role === Role.ADMIN) { - const tierSuffix = (account.tier) ? t("account_basics_tier_admin_suffix_with_tier", { tier: account.tier.name }) : t("account_basics_tier_admin_suffix_no_tier"); - accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`; - } else if (!account.tier) { - accountType = (config.enable_payments) ? t("account_basics_tier_free") : t("account_basics_tier_basic"); - } else { - accountType = account.tier.name; - if (account.billing?.interval === SubscriptionInterval.MONTH) { - accountType += ` (${t("account_basics_tier_interval_monthly")})`; - } else if (account.billing?.interval === SubscriptionInterval.YEAR) { - accountType += ` (${t("account_basics_tier_interval_yearly")})`; - } - } - - return ( - 0} - title={t("account_basics_tier_title")} - description={t("account_basics_tier_description")} - > -
- {accountType} - {account.billing?.paid_until && !account.billing?.cancel_at && - - - - } - {config.enable_payments && account.role === Role.USER && !account.billing?.subscription && - - } - {config.enable_payments && account.role === Role.USER && account.billing?.subscription && - - } - {config.enable_payments && account.role === Role.USER && account.billing?.customer && - - } - {config.enable_payments && - setUpgradeDialogOpen(false)} - /> - } -
- {account.billing?.status === SubscriptionStatus.PAST_DUE && - {t("account_basics_tier_payment_overdue")} - } - {account.billing?.cancel_at > 0 && - {t("account_basics_tier_canceled_subscription", { date: formatShortDate(account.billing.cancel_at) })} - } - - setShowPortalError(false)} - message={t("account_usage_cannot_create_portal_session")} - /> - -
- ) + return ( + 0 + } + title={t("account_basics_tier_title")} + description={t("account_basics_tier_description")} + > +
+ {accountType} + {account.billing?.paid_until && !account.billing?.cancel_at && ( + + + + + + )} + {config.enable_payments && + account.role === Role.USER && + !account.billing?.subscription && ( + + )} + {config.enable_payments && + account.role === Role.USER && + account.billing?.subscription && ( + + )} + {config.enable_payments && + account.role === Role.USER && + account.billing?.customer && ( + + )} + {config.enable_payments && ( + setUpgradeDialogOpen(false)} + /> + )} +
+ {account.billing?.status === SubscriptionStatus.PAST_DUE && ( + + {t("account_basics_tier_payment_overdue")} + + )} + {account.billing?.cancel_at > 0 && ( + + {t("account_basics_tier_canceled_subscription", { + date: formatShortDate(account.billing.cancel_at), + })} + + )} + + setShowPortalError(false)} + message={t("account_usage_cannot_create_portal_session")} + /> + +
+ ); }; const PhoneNumbers = () => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - const [snackOpen, setSnackOpen] = useState(false); - const labelId = "prefPhoneNumbers"; + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const [snackOpen, setSnackOpen] = useState(false); + const labelId = "prefPhoneNumbers"; - const handleDialogOpen = () => { - setDialogKey(prev => prev+1); - setDialogOpen(true); - }; + const handleDialogOpen = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; - const handleDialogClose = () => { - setDialogOpen(false); - }; + const handleDialogClose = () => { + setDialogOpen(false); + }; - const handleCopy = (phoneNumber) => { - navigator.clipboard.writeText(phoneNumber); - setSnackOpen(true); - }; + const handleCopy = (phoneNumber) => { + navigator.clipboard.writeText(phoneNumber); + setSnackOpen(true); + }; - const handleDelete = async (phoneNumber) => { - try { - await accountApi.deletePhoneNumber(phoneNumber); - } catch (e) { - console.log(`[Account] Error deleting phone number`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - } - }; - - if (!config.enable_calls) { - return null; + const handleDelete = async (phoneNumber) => { + try { + await accountApi.deletePhoneNumber(phoneNumber); + } catch (e) { + console.log(`[Account] Error deleting phone number`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } } + }; - if (account?.limits.calls === 0) { - return ( - {t("account_basics_phone_numbers_title")}{config.enable_payments && }} description={t("account_basics_phone_numbers_description")}> - {t("account_usage_calls_none")} - - ) - } + if (!config.enable_calls) { + return null; + } + if (account?.limits.calls === 0) { return ( - -
- {account?.phone_numbers?.map(phoneNumber => - - {phoneNumber} - - } - variant="outlined" - onClick={() => handleCopy(phoneNumber)} - onDelete={() => handleDelete(phoneNumber)} - /> - )} - {!account?.phone_numbers && - {t("account_basics_phone_numbers_no_phone_numbers_yet")} - } - -
- - - setSnackOpen(false)} - message={t("account_basics_phone_numbers_copied_to_clipboard")} - /> - -
- ) + + {t("account_basics_phone_numbers_title")} + {config.enable_payments && } + + } + description={t("account_basics_phone_numbers_description")} + > + {t("account_usage_calls_none")} + + ); + } + + return ( + +
+ {account?.phone_numbers?.map((phoneNumber) => ( + + {phoneNumber} + + } + variant="outlined" + onClick={() => handleCopy(phoneNumber)} + onDelete={() => handleDelete(phoneNumber)} + /> + ))} + {!account?.phone_numbers && ( + {t("account_basics_phone_numbers_no_phone_numbers_yet")} + )} + + + +
+ + + setSnackOpen(false)} + message={t("account_basics_phone_numbers_copied_to_clipboard")} + /> + +
+ ); }; const AddPhoneNumberDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [phoneNumber, setPhoneNumber] = useState(""); - const [channel, setChannel] = useState("sms"); - const [code, setCode] = useState(""); - const [sending, setSending] = useState(false); - const [verificationCodeSent, setVerificationCodeSent] = useState(false); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [channel, setChannel] = useState("sms"); + const [code, setCode] = useState(""); + const [sending, setSending] = useState(false); + const [verificationCodeSent, setVerificationCodeSent] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const handleDialogSubmit = async () => { - if (!verificationCodeSent) { - await verifyPhone(); - } else { - await checkVerifyPhone(); - } - }; + const handleDialogSubmit = async () => { + if (!verificationCodeSent) { + await verifyPhone(); + } else { + await checkVerifyPhone(); + } + }; - const handleCancel = () => { - if (verificationCodeSent) { - setVerificationCodeSent(false); - setCode(""); - } else { - props.onClose(); - } - }; + const handleCancel = () => { + if (verificationCodeSent) { + setVerificationCodeSent(false); + setCode(""); + } else { + props.onClose(); + } + }; - const verifyPhone = async () => { - try { - setSending(true); - await accountApi.verifyPhoneNumber(phoneNumber, channel); - setVerificationCodeSent(true); - } catch (e) { - console.log(`[Account] Error sending verification`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } finally { - setSending(false); - } - }; + const verifyPhone = async () => { + try { + setSending(true); + await accountApi.verifyPhoneNumber(phoneNumber, channel); + setVerificationCodeSent(true); + } catch (e) { + console.log(`[Account] Error sending verification`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } finally { + setSending(false); + } + }; - const checkVerifyPhone = async () => { - try { - setSending(true); - await accountApi.addPhoneNumber(phoneNumber, code); - props.onClose(); - } catch (e) { - console.log(`[Account] Error confirming verification`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } finally { - setSending(false); - } - }; + const checkVerifyPhone = async () => { + try { + setSending(true); + await accountApi.addPhoneNumber(phoneNumber, code); + props.onClose(); + } catch (e) { + console.log(`[Account] Error confirming verification`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } finally { + setSending(false); + } + }; - return ( - - {t("account_basics_phone_numbers_dialog_title")} - - - {t("account_basics_phone_numbers_dialog_description")} - - {!verificationCodeSent && -
- setPhoneNumber(ev.target.value)} - inputProps={{ inputMode: 'tel', pattern: '\+[0-9]*' }} - variant="standard" - sx={{ flexGrow: 1 }} - /> - - - setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_sms")} /> - setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_call")} sx={{ marginRight: 0 }} /> - - -
- } - {verificationCodeSent && - setCode(ev.target.value)} - fullWidth - inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} - variant="standard" + return ( + + + {t("account_basics_phone_numbers_dialog_title")} + + + + {t("account_basics_phone_numbers_dialog_description")} + + {!verificationCodeSent && ( +
+ setPhoneNumber(ev.target.value)} + inputProps={{ inputMode: "tel", pattern: "+[0-9]*" }} + variant="standard" + sx={{ flexGrow: 1 }} + /> + + + setChannel(e.target.value)} /> - } - - - - - -
- ); + } + label={t("account_basics_phone_numbers_dialog_channel_sms")} + /> + setChannel(e.target.value)} + /> + } + label={t("account_basics_phone_numbers_dialog_channel_call")} + sx={{ marginRight: 0 }} + /> + + +

+ )} + {verificationCodeSent && ( + setCode(ev.target.value)} + fullWidth + inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} + variant="standard" + /> + )} + + + + + + + ); }; - const Stats = () => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); + const { t } = useTranslation(); + const { account } = useContext(AccountContext); - if (!account) { - return <>; - } + if (!account) { + return <>; + } - const normalize = (value, max) => { - return Math.min(value / max * 100, 100); - }; + const normalize = (value, max) => { + return Math.min((value / max) * 100, 100); + }; - return ( - - - {t("account_usage_title")} + return ( + + + {t("account_usage_title")} + + + {(account.role === Role.ADMIN || account.limits.reservations > 0) && ( + +
+ + {account.stats.reservations.toLocaleString()} + + + {account.role === Role.USER + ? t("account_usage_of_limit", { + limit: account.limits.reservations.toLocaleString(), + }) + : t("account_usage_unlimited")} + +
+ 0 + ? normalize( + account.stats.reservations, + account.limits.reservations + ) + : 100 + } + /> +
+ )} + + {t("account_usage_messages_title")} + + + + + + + } + > +
+ + {account.stats.messages.toLocaleString()} - - {(account.role === Role.ADMIN || account.limits.reservations > 0) && - -
- {account.stats.reservations.toLocaleString()} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.reservations.toLocaleString() }) : t("account_usage_unlimited")} -
- 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} - /> -
- } - - {t("account_usage_messages_title")} - - - }> -
- {account.stats.messages.toLocaleString()} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.messages.toLocaleString() }) : t("account_usage_unlimited")} -
- -
- {config.enable_emails && - - {t("account_usage_emails_title")} - - - }> -
- {account.stats.emails.toLocaleString()} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.emails.toLocaleString() }) : t("account_usage_unlimited")} -
- -
- } - {config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && - - {t("account_usage_calls_title")} - - - }> -
- {account.stats.calls.toLocaleString()} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: account.limits.calls.toLocaleString() }) : t("account_usage_unlimited")} -
- 0 ? normalize(account.stats.calls, account.limits.calls) : 100} - /> -
- } - -
- {formatBytes(account.stats.attachment_total_size)} - {account.role === Role.USER ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")} -
- -
- {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && - {t("account_usage_reservations_title")}{config.enable_payments && }}> - {t("account_usage_reservations_none")} - - } - {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && - {t("account_usage_calls_title")}{config.enable_payments && }}> - {t("account_usage_calls_none")} - - } -
- {account.role === Role.USER && account.limits.basis === LimitBasis.IP && - - {t("account_usage_basis_ip_description")} - + + {account.role === Role.USER + ? t("account_usage_of_limit", { + limit: account.limits.messages.toLocaleString(), + }) + : t("account_usage_unlimited")} + +
+ - ); + /> +
+ {config.enable_emails && ( + + {t("account_usage_emails_title")} + + + + + + + } + > +
+ + {account.stats.emails.toLocaleString()} + + + {account.role === Role.USER + ? t("account_usage_of_limit", { + limit: account.limits.emails.toLocaleString(), + }) + : t("account_usage_unlimited")} + +
+ +
+ )} + {config.enable_calls && + (account.role === Role.ADMIN || account.limits.calls > 0) && ( + + {t("account_usage_calls_title")} + + + + + + + } + > +
+ + {account.stats.calls.toLocaleString()} + + + {account.role === Role.USER + ? t("account_usage_of_limit", { + limit: account.limits.calls.toLocaleString(), + }) + : t("account_usage_unlimited")} + +
+ 0 + ? normalize(account.stats.calls, account.limits.calls) + : 100 + } + /> +
+ )} + +
+ + {formatBytes(account.stats.attachment_total_size)} + + + {account.role === Role.USER + ? t("account_usage_of_limit", { + limit: formatBytes(account.limits.attachment_total_size), + }) + : t("account_usage_unlimited")} + +
+ +
+ {config.enable_reservations && + account.role === Role.USER && + account.limits.reservations === 0 && ( + + {t("account_usage_reservations_title")} + {config.enable_payments && } + + } + > + {t("account_usage_reservations_none")} + + )} + {config.enable_calls && + account.role === Role.USER && + account.limits.calls === 0 && ( + + {t("account_usage_calls_title")} + {config.enable_payments && } + + } + > + {t("account_usage_calls_none")} + + )} +
+ {account.role === Role.USER && account.limits.basis === LimitBasis.IP && ( + + {t("account_usage_basis_ip_description")} + + )} +
+ ); }; const InfoIcon = () => { - return ( - - ); -} - + return ( + + ); +}; const Tokens = () => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - const tokens = account?.tokens || []; + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const tokens = account?.tokens || []; - const handleCreateClick = () => { - setDialogKey(prev => prev+1); - setDialogOpen(true); - }; + const handleCreateClick = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; - const handleDialogClose = () => { - setDialogOpen(false); - }; + const handleDialogClose = () => { + setDialogOpen(false); + }; - const handleDialogSubmit = async (user) => { - setDialogOpen(false); - // - }; - return ( - - - - {t("account_tokens_title")} - - - - }} - /> - - {tokens?.length > 0 && } - - - - - - - ); + const handleDialogSubmit = async (user) => { + setDialogOpen(false); + // + }; + return ( + + + + {t("account_tokens_title")} + + + , + }} + /> + + {tokens?.length > 0 && } + + + + + + + ); }; const TokensTable = (props) => { - const { t } = useTranslation(); - const [snackOpen, setSnackOpen] = useState(false); - const [upsertDialogKey, setUpsertDialogKey] = useState(0); - const [upsertDialogOpen, setUpsertDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [selectedToken, setSelectedToken] = useState(null); + const { t } = useTranslation(); + const [snackOpen, setSnackOpen] = useState(false); + const [upsertDialogKey, setUpsertDialogKey] = useState(0); + const [upsertDialogOpen, setUpsertDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedToken, setSelectedToken] = useState(null); - const tokens = (props.tokens || []) - .sort( (a, b) => { - if (a.token === session.token()) { - return -1; - } else if (b.token === session.token()) { - return 1; - } - return a.token.localeCompare(b.token); - }); + const tokens = (props.tokens || []).sort((a, b) => { + if (a.token === session.token()) { + return -1; + } else if (b.token === session.token()) { + return 1; + } + return a.token.localeCompare(b.token); + }); - const handleEditClick = (token) => { - setUpsertDialogKey(prev => prev+1); - setSelectedToken(token); - setUpsertDialogOpen(true); - }; + const handleEditClick = (token) => { + setUpsertDialogKey((prev) => prev + 1); + setSelectedToken(token); + setUpsertDialogOpen(true); + }; - const handleDialogClose = () => { - setUpsertDialogOpen(false); - setDeleteDialogOpen(false); - setSelectedToken(null); - }; + const handleDialogClose = () => { + setUpsertDialogOpen(false); + setDeleteDialogOpen(false); + setSelectedToken(null); + }; - const handleDeleteClick = async (token) => { - setSelectedToken(token); - setDeleteDialogOpen(true); - }; + const handleDeleteClick = async (token) => { + setSelectedToken(token); + setDeleteDialogOpen(true); + }; - const handleCopy = async (token) => { - await navigator.clipboard.writeText(token); - setSnackOpen(true); - }; + const handleCopy = async (token) => { + await navigator.clipboard.writeText(token); + setSnackOpen(true); + }; - return ( - - - - {t("account_tokens_table_token_header")} - {t("account_tokens_table_label_header")} - {t("account_tokens_table_expires_header")} - {t("account_tokens_table_last_access_header")} - - - - - {tokens.map(token => ( - - - - {token.token.slice(0, 12)} - ... - - handleCopy(token.token)}> - - - - - {token.token === session.token() && {t("account_tokens_table_current_session")}} - {token.token !== session.token() && (token.label || "-")} - - - {token.expires ? formatShortDateTime(token.expires) : {t("account_tokens_table_never_expires")}} - - -
- {formatShortDateTime(token.last_access)} - - openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}> - - - -
-
- - {token.token !== session.token() && - <> - handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> - - - handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}> - - - - } - {token.token === session.token() && - - - - - - - } - -
- ))} -
- - setSnackOpen(false)} - message={t("account_tokens_table_copied_to_clipboard")} - /> - - - -
- ); + return ( + + + + + {t("account_tokens_table_token_header")} + + {t("account_tokens_table_label_header")} + {t("account_tokens_table_expires_header")} + {t("account_tokens_table_last_access_header")} + + + + + {tokens.map((token) => ( + + + + + {token.token.slice(0, 12)} + + ... + + handleCopy(token.token)}> + + + + + + + {token.token === session.token() && ( + {t("account_tokens_table_current_session")} + )} + {token.token !== session.token() && (token.label || "-")} + + + {token.expires ? ( + formatShortDateTime(token.expires) + ) : ( + {t("account_tokens_table_never_expires")} + )} + + +
+ {formatShortDateTime(token.last_access)} + + + openUrl( + `https://whatismyipaddress.com/ip/${token.last_origin}` + ) + } + > + + + +
+
+ + {token.token !== session.token() && ( + <> + handleEditClick(token)} + aria-label={t("account_tokens_dialog_title_edit")} + > + + + handleDeleteClick(token)} + aria-label={t("account_tokens_dialog_title_delete")} + > + + + + )} + {token.token === session.token() && ( + + + + + + + + + + + )} + +
+ ))} +
+ + setSnackOpen(false)} + message={t("account_tokens_table_copied_to_clipboard")} + /> + + + +
+ ); }; const TokenDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [label, setLabel] = useState(props.token?.label || ""); - const [expires, setExpires] = useState(props.token ? -1 : 0); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - const editMode = !!props.token; + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [label, setLabel] = useState(props.token?.label || ""); + const [expires, setExpires] = useState(props.token ? -1 : 0); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + const editMode = !!props.token; - const handleSubmit = async () => { - try { - if (editMode) { - await accountApi.updateToken(props.token.token, label, expires); - } else { - await accountApi.createToken(label, expires); - } - props.onClose(); - } catch (e) { - console.log(`[Account] Error creating token`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } - }; + const handleSubmit = async () => { + try { + if (editMode) { + await accountApi.updateToken(props.token.token, label, expires); + } else { + await accountApi.createToken(label, expires); + } + props.onClose(); + } catch (e) { + console.log(`[Account] Error creating token`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } + }; - return ( - - {editMode ? t("account_tokens_dialog_title_edit") : t("account_tokens_dialog_title_create")} - - setLabel(ev.target.value)} - fullWidth - variant="standard" - /> - - - - - - - - - - ); + return ( + + + {editMode + ? t("account_tokens_dialog_title_edit") + : t("account_tokens_dialog_title_create")} + + + setLabel(ev.target.value)} + fullWidth + variant="standard" + /> + + + + + + + + + + ); }; const TokenDeleteDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); + const { t } = useTranslation(); + const [error, setError] = useState(""); - const handleSubmit = async () => { - try { - await accountApi.deleteToken(props.token.token); - props.onClose(); - } catch (e) { - console.log(`[Account] Error deleting token`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } - }; - - return ( - - {t("account_tokens_delete_dialog_title")} - - - - - - - - - - - ); -} + const handleSubmit = async () => { + try { + await accountApi.deleteToken(props.token.token); + props.onClose(); + } catch (e) { + console.log(`[Account] Error deleting token`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } + }; + return ( + + {t("account_tokens_delete_dialog_title")} + + + + + + + + + + + ); +}; const Delete = () => { - const { t } = useTranslation(); - return ( - - - {t("account_delete_title")} - - - - - - ); + const { t } = useTranslation(); + return ( + + + {t("account_delete_title")} + + + + + + ); }; const DeleteAccount = () => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); - const handleDialogOpen = () => { - setDialogKey(prev => prev+1); - setDialogOpen(true); - }; + const handleDialogOpen = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; - const handleDialogClose = () => { - setDialogOpen(false); - }; + const handleDialogClose = () => { + setDialogOpen(false); + }; - return ( - -
- -
- -
- ) + return ( + +
+ +
+ +
+ ); }; const DeleteAccountDialog = (props) => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [error, setError] = useState(""); - const [password, setPassword] = useState(""); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [error, setError] = useState(""); + const [password, setPassword] = useState(""); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const handleSubmit = async () => { - try { - await accountApi.delete(password); - await db.delete(); - console.debug(`[Account] Account deleted`); - session.resetAndRedirect(routes.app); - } catch (e) { - console.log(`[Account] Error deleting account`, e); - if (e instanceof IncorrectPasswordError) { - setError(t("account_basics_password_dialog_current_password_incorrect")); - } else if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } - }; + const handleSubmit = async () => { + try { + await accountApi.delete(password); + await db.delete(); + console.debug(`[Account] Account deleted`); + session.resetAndRedirect(routes.app); + } catch (e) { + console.log(`[Account] Error deleting account`, e); + if (e instanceof IncorrectPasswordError) { + setError( + t("account_basics_password_dialog_current_password_incorrect") + ); + } else if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } + }; - return ( - - {t("account_delete_title")} - - - {t("account_delete_dialog_description")} - - setPassword(ev.target.value)} - fullWidth - variant="standard" - /> - {account?.billing?.subscription && - {t("account_delete_dialog_billing_warning")} - } - - - - - - - ); + return ( + + {t("account_delete_title")} + + + {t("account_delete_dialog_description")} + + setPassword(ev.target.value)} + fullWidth + variant="standard" + /> + {account?.billing?.subscription && ( + + {t("account_delete_dialog_billing_warning")} + + )} + + + + + + + ); }; export default Account; diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index 189ae1c..b6c8416 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -5,179 +5,219 @@ import IconButton from "@mui/material/IconButton"; import MenuIcon from "@mui/icons-material/Menu"; import Typography from "@mui/material/Typography"; import * as React from "react"; -import {useState} from "react"; +import { useState } from "react"; import Box from "@mui/material/Box"; -import {topicDisplayName} from "../app/utils"; +import { topicDisplayName } from "../app/utils"; import db from "../app/db"; -import {useLocation, useNavigate} from "react-router-dom"; -import MenuItem from '@mui/material/MenuItem'; +import { useLocation, useNavigate } from "react-router-dom"; +import MenuItem from "@mui/material/MenuItem"; import MoreVertIcon from "@mui/icons-material/MoreVert"; -import NotificationsIcon from '@mui/icons-material/Notifications'; -import NotificationsOffIcon from '@mui/icons-material/NotificationsOff'; +import NotificationsIcon from "@mui/icons-material/Notifications"; +import NotificationsOffIcon from "@mui/icons-material/NotificationsOff"; import routes from "./routes"; import subscriptionManager from "../app/SubscriptionManager"; import logo from "../img/ntfy.svg"; -import {useTranslation} from "react-i18next"; +import { useTranslation } from "react-i18next"; import session from "../app/Session"; -import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import Button from "@mui/material/Button"; import Divider from "@mui/material/Divider"; -import {Logout, Person, Settings} from "@mui/icons-material"; +import { Logout, Person, Settings } from "@mui/icons-material"; import ListItemIcon from "@mui/material/ListItemIcon"; import accountApi from "../app/AccountApi"; import PopupMenu from "./PopupMenu"; import { SubscriptionPopup } from "./SubscriptionPopup"; const ActionBar = (props) => { - const { t } = useTranslation(); - const location = useLocation(); - let title = "ntfy"; - if (props.selected) { - title = topicDisplayName(props.selected); - } else if (location.pathname === routes.settings) { - title = t("action_bar_settings"); - } else if (location.pathname === routes.account) { - title = t("action_bar_account"); - } - return ( - Navigation (1200), but < Dialog (1300) - ml: { sm: `${Navigation.width}px` } - }}> - - - - - - - {title} - - {props.selected && - } - - - - ); + const { t } = useTranslation(); + const location = useLocation(); + let title = "ntfy"; + if (props.selected) { + title = topicDisplayName(props.selected); + } else if (location.pathname === routes.settings) { + title = t("action_bar_settings"); + } else if (location.pathname === routes.account) { + title = t("action_bar_account"); + } + return ( + Navigation (1200), but < Dialog (1300) + ml: { sm: `${Navigation.width}px` }, + }} + > + + + + + + + {title} + + {props.selected && ( + + )} + + + + ); }; const SettingsIcons = (props) => { - const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = useState(null); - const subscription = props.subscription; + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const subscription = props.subscription; - const handleToggleMute = async () => { - const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future - await subscriptionManager.setMutedUntil(subscription.id, mutedUntil); - } + const handleToggleMute = async () => { + const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future + await subscriptionManager.setMutedUntil(subscription.id, mutedUntil); + }; - return ( - <> - - {subscription.mutedUntil ? : } - - setAnchorEl(ev.currentTarget)} aria-label={t("action_bar_toggle_action_menu")}> - - - setAnchorEl(null)} - /> - - ); + return ( + <> + + {subscription.mutedUntil ? ( + + ) : ( + + )} + + setAnchorEl(ev.currentTarget)} + aria-label={t("action_bar_toggle_action_menu")} + > + + + setAnchorEl(null)} + /> + + ); }; const ProfileIcon = () => { - const { t } = useTranslation(); - const [anchorEl, setAnchorEl] = useState(null); - const open = Boolean(anchorEl); - const navigate = useNavigate(); + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const navigate = useNavigate(); - const handleClick = (event) => { - setAnchorEl(event.currentTarget); - }; + const handleClick = (event) => { + setAnchorEl(event.currentTarget); + }; - const handleClose = () => { - setAnchorEl(null); - }; + const handleClose = () => { + setAnchorEl(null); + }; - const handleLogout = async () => { - try { - await accountApi.logout(); - await db.delete(); - } finally { - session.resetAndRedirect(routes.app); - } - }; + const handleLogout = async () => { + try { + await accountApi.logout(); + await db.delete(); + } finally { + session.resetAndRedirect(routes.app); + } + }; - return ( - <> - {session.exists() && - - - - } - {!session.exists() && config.enable_login && - - } - {!session.exists() && config.enable_signup && - - } - - navigate(routes.account)}> - - - - {session.username()} - - - navigate(routes.settings)}> - - - - {t("action_bar_profile_settings")} - - - - - - {t("action_bar_profile_logout")} - - - - ); + return ( + <> + {session.exists() && ( + + + + )} + {!session.exists() && config.enable_login && ( + + )} + {!session.exists() && config.enable_signup && ( + + )} + + navigate(routes.account)}> + + + + {session.username()} + + + navigate(routes.settings)}> + + + + {t("action_bar_profile_settings")} + + + + + + {t("action_bar_profile_logout")} + + + + ); }; export default ActionBar; diff --git a/web/src/components/App.js b/web/src/components/App.js index 861a370..b2c204a 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -1,27 +1,43 @@ -import * as React from 'react'; -import {createContext, Suspense, useContext, useEffect, useState} from 'react'; -import Box from '@mui/material/Box'; -import {ThemeProvider} from '@mui/material/styles'; -import CssBaseline from '@mui/material/CssBaseline'; -import Toolbar from '@mui/material/Toolbar'; -import {AllSubscriptions, SingleSubscription} from "./Notifications"; +import * as React from "react"; +import { + createContext, + Suspense, + useContext, + useEffect, + useState, +} from "react"; +import Box from "@mui/material/Box"; +import { ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import Toolbar from "@mui/material/Toolbar"; +import { AllSubscriptions, SingleSubscription } from "./Notifications"; import theme from "./theme"; import Navigation from "./Navigation"; import ActionBar from "./ActionBar"; import notifier from "../app/Notifier"; import Preferences from "./Preferences"; -import {useLiveQuery} from "dexie-react-hooks"; +import { useLiveQuery } from "dexie-react-hooks"; import subscriptionManager from "../app/SubscriptionManager"; import userManager from "../app/UserManager"; -import {BrowserRouter, Outlet, Route, Routes, useParams} from "react-router-dom"; -import {expandUrl} from "../app/utils"; +import { + BrowserRouter, + Outlet, + Route, + Routes, + useParams, +} from "react-router-dom"; +import { expandUrl } from "../app/utils"; import ErrorBoundary from "./ErrorBoundary"; import routes from "./routes"; -import {useAccountListener, useBackgroundProcesses, useConnectionListeners} from "./hooks"; +import { + useAccountListener, + useBackgroundProcesses, + useConnectionListeners, +} from "./hooks"; import PublishDialog from "./PublishDialog"; import Messaging from "./Messaging"; import "./i18n"; // Translations! -import {Backdrop, CircularProgress} from "@mui/material"; +import { Backdrop, CircularProgress } from "@mui/material"; import Login from "./Login"; import Signup from "./Signup"; import Account from "./Account"; @@ -29,119 +45,145 @@ import Account from "./Account"; export const AccountContext = createContext(null); const App = () => { - const [account, setAccount] = useState(null); - return ( - }> - - - - - - - }/> - }/> - }> - }/> - }/> - }/> - }/> - }/> - - - - - - - - ); -} + const [account, setAccount] = useState(null); + return ( + }> + + + + + + + } /> + } /> + }> + } /> + } /> + } /> + } + /> + } + /> + + + + + + + + ); +}; const Layout = () => { - const params = useParams(); - const { account, setAccount } = useContext(AccountContext); - const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); - const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted()); - const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); - const users = useLiveQuery(() => userManager.all()); - const subscriptions = useLiveQuery(() => subscriptionManager.all()); - const subscriptionsWithoutInternal = subscriptions?.filter(s => !s.internal); - const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; - const [selected] = (subscriptionsWithoutInternal || []).filter(s => { - return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) - || (config.base_url === s.baseUrl && params.topic === s.topic) - }); - - useConnectionListeners(account, subscriptions, users); - useAccountListener(setAccount) - useBackgroundProcesses(); - useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); - + const params = useParams(); + const { account, setAccount } = useContext(AccountContext); + const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); + const [notificationsGranted, setNotificationsGranted] = useState( + notifier.granted() + ); + const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); + const users = useLiveQuery(() => userManager.all()); + const subscriptions = useLiveQuery(() => subscriptionManager.all()); + const subscriptionsWithoutInternal = subscriptions?.filter( + (s) => !s.internal + ); + const newNotificationsCount = + subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; + const [selected] = (subscriptionsWithoutInternal || []).filter((s) => { return ( - - setMobileDrawerOpen(!mobileDrawerOpen)} - /> - setMobileDrawerOpen(!mobileDrawerOpen)} - onNotificationGranted={setNotificationsGranted} - onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} - /> -
- - -
- -
+ (params.baseUrl && + expandUrl(params.baseUrl).includes(s.baseUrl) && + params.topic === s.topic) || + (config.base_url === s.baseUrl && params.topic === s.topic) ); -} + }); + + useConnectionListeners(account, subscriptions, users); + useAccountListener(setAccount); + useBackgroundProcesses(); + useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]); + + return ( + + setMobileDrawerOpen(!mobileDrawerOpen)} + /> + setMobileDrawerOpen(!mobileDrawerOpen)} + onNotificationGranted={setNotificationsGranted} + onPublishMessageClick={() => + setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT) + } + /> +
+ + +
+ +
+ ); +}; const Main = (props) => { - return ( - theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] - }} - > - {props.children} - - ); + return ( + + theme.palette.mode === "light" + ? theme.palette.grey[100] + : theme.palette.grey[900], + }} + > + {props.children} + + ); }; const Loader = () => ( - theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] - }} - > - - + + theme.palette.mode === "light" + ? theme.palette.grey[100] + : theme.palette.grey[900], + }} + > + + ); const updateTitle = (newNotificationsCount) => { - document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy"; -} + document.title = + newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy"; +}; export default App; diff --git a/web/src/components/AttachmentIcon.js b/web/src/components/AttachmentIcon.js index 337760b..9939b3b 100644 --- a/web/src/components/AttachmentIcon.js +++ b/web/src/components/AttachmentIcon.js @@ -5,43 +5,43 @@ import fileImage from "../img/file-image.svg"; import fileVideo from "../img/file-video.svg"; import fileAudio from "../img/file-audio.svg"; import fileApp from "../img/file-app.svg"; -import {useTranslation} from "react-i18next"; +import { useTranslation } from "react-i18next"; const AttachmentIcon = (props) => { - const { t } = useTranslation(); - const type = props.type; - let imageFile, imageLabel; - if (!type) { - imageFile = fileDocument; - imageLabel = t("notifications_attachment_file_image"); - } else if (type.startsWith('image/')) { - imageFile = fileImage; - imageLabel = t("notifications_attachment_file_video"); - } else if (type.startsWith('video/')) { - imageFile = fileVideo; - imageLabel = t("notifications_attachment_file_video"); - } else if (type.startsWith('audio/')) { - imageFile = fileAudio; - imageLabel = t("notifications_attachment_file_audio"); - } else if (type === "application/vnd.android.package-archive") { - imageFile = fileApp; - imageLabel = t("notifications_attachment_file_app"); - } else { - imageFile = fileDocument; - imageLabel = t("notifications_attachment_file_document"); - } - return ( - - ); -} + const { t } = useTranslation(); + const type = props.type; + let imageFile, imageLabel; + if (!type) { + imageFile = fileDocument; + imageLabel = t("notifications_attachment_file_image"); + } else if (type.startsWith("image/")) { + imageFile = fileImage; + imageLabel = t("notifications_attachment_file_video"); + } else if (type.startsWith("video/")) { + imageFile = fileVideo; + imageLabel = t("notifications_attachment_file_video"); + } else if (type.startsWith("audio/")) { + imageFile = fileAudio; + imageLabel = t("notifications_attachment_file_audio"); + } else if (type === "application/vnd.android.package-archive") { + imageFile = fileApp; + imageLabel = t("notifications_attachment_file_app"); + } else { + imageFile = fileDocument; + imageLabel = t("notifications_attachment_file_document"); + } + return ( + + ); +}; export default AttachmentIcon; diff --git a/web/src/components/AvatarBox.js b/web/src/components/AvatarBox.js index 2278f60..5a612f1 100644 --- a/web/src/components/AvatarBox.js +++ b/web/src/components/AvatarBox.js @@ -1,29 +1,29 @@ -import * as React from 'react'; -import {Avatar} from "@mui/material"; +import * as React from "react"; +import { Avatar } from "@mui/material"; import Box from "@mui/material/Box"; import logo from "../img/ntfy-filled.svg"; const AvatarBox = (props) => { - return ( - - - {props.children} - - ); -} + return ( + + + {props.children} + + ); +}; export default AvatarBox; diff --git a/web/src/components/DialogFooter.js b/web/src/components/DialogFooter.js index 68d17c7..5a2bd7a 100644 --- a/web/src/components/DialogFooter.js +++ b/web/src/components/DialogFooter.js @@ -4,30 +4,30 @@ import DialogContentText from "@mui/material/DialogContentText"; import DialogActions from "@mui/material/DialogActions"; const DialogFooter = (props) => { - return ( - - - {props.status} - - - {props.children} - - - ); + return ( + + + {props.status} + + {props.children} + + ); }; export default DialogFooter; diff --git a/web/src/components/EmojiPicker.js b/web/src/components/EmojiPicker.js index 9b29e8f..03badb7 100644 --- a/web/src/components/EmojiPicker.js +++ b/web/src/components/EmojiPicker.js @@ -1,15 +1,15 @@ -import * as React from 'react'; -import {useRef, useState} from 'react'; -import Typography from '@mui/material/Typography'; -import {rawEmojis} from '../app/emojis'; +import * as React from "react"; +import { useRef, useState } from "react"; +import Typography from "@mui/material/Typography"; +import { rawEmojis } from "../app/emojis"; import Box from "@mui/material/Box"; import TextField from "@mui/material/TextField"; -import {ClickAwayListener, Fade, InputAdornment, styled} from "@mui/material"; +import { ClickAwayListener, Fade, InputAdornment, styled } from "@mui/material"; import IconButton from "@mui/material/IconButton"; -import {Close} from "@mui/icons-material"; +import { Close } from "@mui/icons-material"; import Popper from "@mui/material/Popper"; -import {splitNoEmpty} from "../app/utils"; -import {useTranslation} from "react-i18next"; +import { splitNoEmpty } from "../app/utils"; +import { useTranslation } from "react-i18next"; // Create emoji list by category and create a search base (string with all search words) // @@ -17,163 +17,185 @@ import {useTranslation} from "react-i18next"; // This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported. const emojisByCategory = {}; -const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent); +const isDesktopChrome = + /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent); const maxSupportedVersionForDesktopChrome = 11; -rawEmojis.forEach(emoji => { - if (!emojisByCategory[emoji.category]) { - emojisByCategory[emoji.category] = []; - } - try { - const unicodeVersion = parseFloat(emoji.unicode_version); - const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; - if (supportedEmoji) { - const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`; - const emojiWithSearchBase = { ...emoji, searchBase: searchBase }; - emojisByCategory[emoji.category].push(emojiWithSearchBase); - } - } catch (e) { - // Nothing. Ignore. +rawEmojis.forEach((emoji) => { + if (!emojisByCategory[emoji.category]) { + emojisByCategory[emoji.category] = []; + } + try { + const unicodeVersion = parseFloat(emoji.unicode_version); + const supportedEmoji = + unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; + if (supportedEmoji) { + const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join( + " " + )} ${emoji.tags.join(" ")}`; + const emojiWithSearchBase = { ...emoji, searchBase: searchBase }; + emojisByCategory[emoji.category].push(emojiWithSearchBase); } + } catch (e) { + // Nothing. Ignore. + } }); const EmojiPicker = (props) => { - const { t } = useTranslation(); - const open = Boolean(props.anchorEl); - const [search, setSearch] = useState(""); - const searchRef = useRef(null); - const searchFields = splitNoEmpty(search.toLowerCase(), " "); + const { t } = useTranslation(); + const open = Boolean(props.anchorEl); + const [search, setSearch] = useState(""); + const searchRef = useRef(null); + const searchFields = splitNoEmpty(search.toLowerCase(), " "); - const handleSearchClear = () => { - setSearch(""); - searchRef.current?.focus(); - }; + const handleSearchClear = () => { + setSearch(""); + searchRef.current?.focus(); + }; - return ( - - {({ TransitionProps }) => ( - - - - setSearch(ev.target.value)} - type="text" - variant="standard" - fullWidth - sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }} - inputProps={{ - role: "searchbox", - "aria-label": t("emoji_picker_search_placeholder") - }} - InputProps={{ - endAdornment: - - - - - - }} - /> - - {Object.keys(emojisByCategory).map(category => - - )} - - - - - )} - - ); + return ( + + {({ TransitionProps }) => ( + + + + setSearch(ev.target.value)} + type="text" + variant="standard" + fullWidth + sx={{ marginTop: 0, marginBottom: "12px", paddingRight: 2 }} + inputProps={{ + role: "searchbox", + "aria-label": t("emoji_picker_search_placeholder"), + }} + InputProps={{ + endAdornment: ( + + + + + + ), + }} + /> + + {Object.keys(emojisByCategory).map((category) => ( + + ))} + + + + + )} + + ); }; const Category = (props) => { - const showTitle = props.search.length === 0; - return ( - <> - {showTitle && - - {props.title} - - } - {props.emojis.map(emoji => - props.onPick(emoji.aliases[0])} - /> - )} - - ); + const showTitle = props.search.length === 0; + return ( + <> + {showTitle && ( + + {props.title} + + )} + {props.emojis.map((emoji) => ( + props.onPick(emoji.aliases[0])} + /> + ))} + + ); }; const Emoji = (props) => { - const emoji = props.emoji; - const matches = emojiMatches(emoji, props.search); - const title = `${emoji.description} (${emoji.aliases[0]})`; - return ( - - {props.emoji.emoji} - - ); + const emoji = props.emoji; + const matches = emojiMatches(emoji, props.search); + const title = `${emoji.description} (${emoji.aliases[0]})`; + return ( + + {props.emoji.emoji} + + ); }; const EmojiDiv = styled("div")({ - fontSize: "30px", - width: "30px", - height: "30px", - marginTop: "8px", - marginBottom: "8px", - marginRight: "8px", - lineHeight: "30px", - cursor: "pointer", - opacity: 0.85, - "&:hover": { - opacity: 1 - } + fontSize: "30px", + width: "30px", + height: "30px", + marginTop: "8px", + marginBottom: "8px", + marginRight: "8px", + lineHeight: "30px", + cursor: "pointer", + opacity: 0.85, + "&:hover": { + opacity: 1, + }, }); const emojiMatches = (emoji, words) => { - if (words.length === 0) { - return true; - } - for (const word of words) { - if (emoji.searchBase.indexOf(word) === -1) { - return false; - } - } + if (words.length === 0) { return true; -} + } + for (const word of words) { + if (emoji.searchBase.indexOf(word) === -1) { + return false; + } + } + return true; +}; export default EmojiPicker; diff --git a/web/src/components/ErrorBoundary.js b/web/src/components/ErrorBoundary.js index c6d789a..f1ce7c2 100644 --- a/web/src/components/ErrorBoundary.js +++ b/web/src/components/ErrorBoundary.js @@ -1,128 +1,151 @@ import * as React from "react"; import StackTrace from "stacktrace-js"; -import {CircularProgress, Link} from "@mui/material"; +import { CircularProgress, Link } from "@mui/material"; import Button from "@mui/material/Button"; -import {Trans, withTranslation} from "react-i18next"; +import { Trans, withTranslation } from "react-i18next"; class ErrorBoundaryImpl extends React.Component { - constructor(props) { - super(props); - this.state = { - error: false, - originalStack: null, - niceStack: null, - unsupportedIndexedDB: false - }; + constructor(props) { + super(props); + this.state = { + error: false, + originalStack: null, + niceStack: null, + unsupportedIndexedDB: false, + }; + } + + componentDidCatch(error, info) { + console.error("[ErrorBoundary] Error caught", error, info); + + // Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see + // - https://github.com/dexie/Dexie.js/issues/312 + // - https://bugzilla.mozilla.org/show_bug.cgi?id=781982 + const isUnsupportedIndexedDB = + error?.name === "InvalidStateError" || + (error?.name === "DatabaseClosedError" && + error?.message?.indexOf("InvalidStateError") !== -1); + + if (isUnsupportedIndexedDB) { + this.handleUnsupportedIndexedDB(); + } else { + this.handleError(error, info); } + } - componentDidCatch(error, info) { - console.error("[ErrorBoundary] Error caught", error, info); + handleError(error, info) { + // Immediately render original stack trace + const prettierOriginalStack = info.componentStack + .trim() + .split("\n") + .map((line) => ` at ${line}`) + .join("\n"); + this.setState({ + error: true, + originalStack: `${error.toString()}\n${prettierOriginalStack}`, + }); - // Special case for unsupported IndexedDB in Private Browsing mode (Firefox, Safari), see - // - https://github.com/dexie/Dexie.js/issues/312 - // - https://bugzilla.mozilla.org/show_bug.cgi?id=781982 - const isUnsupportedIndexedDB = error?.name === "InvalidStateError" || - (error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1); + // Fetch additional info and a better stack trace + StackTrace.fromError(error).then((stack) => { + console.error("[ErrorBoundary] Stacktrace fetched", stack); + const niceStack = + `${error.toString()}\n` + + stack + .map( + (el) => + ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})` + ) + .join("\n"); + this.setState({ niceStack }); + }); + } - if (isUnsupportedIndexedDB) { - this.handleUnsupportedIndexedDB(); - } else { - this.handleError(error, info); - } + handleUnsupportedIndexedDB() { + this.setState({ + error: true, + unsupportedIndexedDB: true, + }); + } + + copyStack() { + let stack = ""; + if (this.state.niceStack) { + stack += `${this.state.niceStack}\n\n`; } + stack += `${this.state.originalStack}\n`; + navigator.clipboard.writeText(stack); + } - handleError(error, info) { - // Immediately render original stack trace - const prettierOriginalStack = info.componentStack - .trim() - .split("\n") - .map(line => ` at ${line}`) - .join("\n"); - this.setState({ - error: true, - originalStack: `${error.toString()}\n${prettierOriginalStack}` - }); - - // Fetch additional info and a better stack trace - StackTrace.fromError(error).then(stack => { - console.error("[ErrorBoundary] Stacktrace fetched", stack); - const niceStack = `${error.toString()}\n` + stack.map( el => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n"); - this.setState({ niceStack }); - }); + render() { + if (this.state.error) { + if (this.state.unsupportedIndexedDB) { + return this.renderUnsupportedIndexedDB(); + } else { + return this.renderError(); + } } + return this.props.children; + } - handleUnsupportedIndexedDB() { - this.setState({ - error: true, - unsupportedIndexedDB: true - }); - } + renderUnsupportedIndexedDB() { + const { t } = this.props; + return ( +
+

{t("error_boundary_unsupported_indexeddb_title")} 😮

+

+ + ), + discordLink: , + matrixLink: , + }} + /> +

+
+ ); + } - copyStack() { - let stack = ""; - if (this.state.niceStack) { - stack += `${this.state.niceStack}\n\n`; - } - stack += `${this.state.originalStack}\n`; - navigator.clipboard.writeText(stack); - } - - render() { - if (this.state.error) { - if (this.state.unsupportedIndexedDB) { - return this.renderUnsupportedIndexedDB(); - } else { - return this.renderError(); - } - } - return this.props.children; - } - - renderUnsupportedIndexedDB() { - const { t } = this.props; - return ( -
-

{t("error_boundary_unsupported_indexeddb_title")} 😮

-

- , - discordLink: , - matrixLink: - }} - /> -

-
- ); - } - - renderError() { - const { t } = this.props; - return ( -
-

{t("error_boundary_title")} 😮

-

- , - discordLink: , - matrixLink: - }} - /> -

-

- -

-

{t("error_boundary_stack_trace")}

- {this.state.niceStack - ?
{this.state.niceStack}
- : <> {t("error_boundary_gathering_info")}} -
{this.state.originalStack}
-
- ); - } + renderError() { + const { t } = this.props; + return ( +
+

{t("error_boundary_title")} 😮

+

+ + ), + discordLink: , + matrixLink: , + }} + /> +

+

+ +

+

{t("error_boundary_stack_trace")}

+ {this.state.niceStack ? ( +
{this.state.niceStack}
+ ) : ( + <> + {" "} + {t("error_boundary_gathering_info")} + + )} +
{this.state.originalStack}
+
+ ); + } } const ErrorBoundary = withTranslation()(ErrorBoundaryImpl); // Adds props.t diff --git a/web/src/components/Login.js b/web/src/components/Login.js index 8b14c53..a109ae6 100644 --- a/web/src/components/Login.js +++ b/web/src/components/Login.js @@ -1,122 +1,135 @@ -import * as React from 'react'; -import {useState} from 'react'; +import * as React from "react"; +import { useState } from "react"; import Typography from "@mui/material/Typography"; -import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import TextField from "@mui/material/TextField"; import Button from "@mui/material/Button"; import Box from "@mui/material/Box"; import routes from "./routes"; import session from "../app/Session"; -import {NavLink} from "react-router-dom"; +import { NavLink } from "react-router-dom"; import AvatarBox from "./AvatarBox"; -import {useTranslation} from "react-i18next"; +import { useTranslation } from "react-i18next"; import accountApi from "../app/AccountApi"; import IconButton from "@mui/material/IconButton"; -import {InputAdornment} from "@mui/material"; -import {Visibility, VisibilityOff} from "@mui/icons-material"; -import {UnauthorizedError} from "../app/errors"; +import { InputAdornment } from "@mui/material"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { UnauthorizedError } from "../app/errors"; const Login = () => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [showPassword, setShowPassword] = useState(false); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); - const handleSubmit = async (event) => { - event.preventDefault(); - const user = { username, password }; - try { - const token = await accountApi.login(user); - console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); - session.store(user.username, token); - window.location.href = routes.app; - } catch (e) { - console.log(`[Login] User auth for user ${user.username} failed`, e); - if (e instanceof UnauthorizedError) { - setError(t("Login failed: Invalid username or password")); - } else { - setError(e.message); - } - } - }; - if (!config.enable_login) { - return ( - - {t("login_disabled")} - - ); + const handleSubmit = async (event) => { + event.preventDefault(); + const user = { username, password }; + try { + const token = await accountApi.login(user); + console.log( + `[Login] User auth for user ${user.username} successful, token is ${token}` + ); + session.store(user.username, token); + window.location.href = routes.app; + } catch (e) { + console.log(`[Login] User auth for user ${user.username} failed`, e); + if (e instanceof UnauthorizedError) { + setError(t("Login failed: Invalid username or password")); + } else { + setError(e.message); + } } + }; + if (!config.enable_login) { return ( - - - {t("login_title")} - - - setUsername(ev.target.value.trim())} - autoFocus - /> - setPassword(ev.target.value.trim())} - autoComplete="current-password" - InputProps={{ - endAdornment: ( - - setShowPassword(!showPassword)} - onMouseDown={(ev) => ev.preventDefault()} - edge="end" - > - {showPassword ? : } - - - ) - }} - /> - - {error && - - - {error} - - } - - {/* This is where the password reset link would go */} - {config.enable_signup &&
{t("login_link_signup")}
} -
-
-
+ + {t("login_disabled")} + ); -} + } + return ( + + {t("login_title")} + + setUsername(ev.target.value.trim())} + autoFocus + /> + setPassword(ev.target.value.trim())} + autoComplete="current-password" + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} + onMouseDown={(ev) => ev.preventDefault()} + edge="end" + > + {showPassword ? : } + + + ), + }} + /> + + {error && ( + + + {error} + + )} + + {/* This is where the password reset link would go */} + {config.enable_signup && ( +
+ + {t("login_link_signup")} + +
+ )} +
+
+
+ ); +}; export default Login; diff --git a/web/src/components/Messaging.js b/web/src/components/Messaging.js index b1f11a9..d69bc57 100644 --- a/web/src/components/Messaging.js +++ b/web/src/components/Messaging.js @@ -1,5 +1,5 @@ -import * as React from 'react'; -import {useState} from 'react'; +import * as React from "react"; +import { useState } from "react"; import Navigation from "./Navigation"; import Paper from "@mui/material/Paper"; import IconButton from "@mui/material/IconButton"; @@ -7,108 +7,135 @@ import TextField from "@mui/material/TextField"; import SendIcon from "@mui/icons-material/Send"; import api from "../app/Api"; import PublishDialog from "./PublishDialog"; -import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; -import {Portal, Snackbar} from "@mui/material"; -import {useTranslation} from "react-i18next"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import { Portal, Snackbar } from "@mui/material"; +import { useTranslation } from "react-i18next"; const Messaging = (props) => { - const [message, setMessage] = useState(""); - const [dialogKey, setDialogKey] = useState(0); + const [message, setMessage] = useState(""); + const [dialogKey, setDialogKey] = useState(0); - const dialogOpenMode = props.dialogOpenMode; - const subscription = props.selected; + const dialogOpenMode = props.dialogOpenMode; + const subscription = props.selected; - const handleOpenDialogClick = () => { - props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT); - }; + const handleOpenDialogClick = () => { + props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT); + }; - const handleDialogClose = () => { - props.onDialogOpenModeChange(""); - setDialogKey(prev => prev+1); - }; + const handleDialogClose = () => { + props.onDialogOpenModeChange(""); + setDialogKey((prev) => prev + 1); + }; - return ( - <> - {subscription && } - props.onDialogOpenModeChange(prev => (prev) ? prev : PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open - onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)} - /> - - ); -} + return ( + <> + {subscription && ( + + )} + + props.onDialogOpenModeChange((prev) => + prev ? prev : PublishDialog.OPEN_MODE_DRAG + ) + } // Only update if not already open + onResetOpenMode={() => + props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT) + } + /> + + ); +}; const MessageBar = (props) => { - const { t } = useTranslation(); - const subscription = props.subscription; - const [snackOpen, setSnackOpen] = useState(false); - const handleSendClick = async () => { - try { - await api.publish(subscription.baseUrl, subscription.topic, props.message); - } catch (e) { - console.log(`[MessageBar] Error publishing message`, e); - setSnackOpen(true); - } - props.onMessageChange(""); - }; - return ( - theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900] - }} - > - - - - props.onMessageChange(ev.target.value)} - onKeyPress={(ev) => { - if (ev.key === 'Enter') { - ev.preventDefault(); - handleSendClick(); - } - }} - /> - - - - - setSnackOpen(false)} - message={t("message_bar_error_publishing")} - /> - - - ); + const { t } = useTranslation(); + const subscription = props.subscription; + const [snackOpen, setSnackOpen] = useState(false); + const handleSendClick = async () => { + try { + await api.publish( + subscription.baseUrl, + subscription.topic, + props.message + ); + } catch (e) { + console.log(`[MessageBar] Error publishing message`, e); + setSnackOpen(true); + } + props.onMessageChange(""); + }; + return ( + + theme.palette.mode === "light" + ? theme.palette.grey[100] + : theme.palette.grey[900], + }} + > + + + + props.onMessageChange(ev.target.value)} + onKeyPress={(ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + handleSendClick(); + } + }} + /> + + + + + setSnackOpen(false)} + message={t("message_bar_error_publishing")} + /> + + + ); }; export default Messaging; diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js index a7d0da0..654e29b 100644 --- a/web/src/components/Navigation.js +++ b/web/src/components/Navigation.js @@ -1,6 +1,6 @@ import Drawer from "@mui/material/Drawer"; import * as React from "react"; -import {useContext, useState} from "react"; +import { useContext, useState } from "react"; import ListItemButton from "@mui/material/ListItemButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline"; @@ -12,360 +12,485 @@ import List from "@mui/material/List"; import SettingsIcon from "@mui/icons-material/Settings"; import AddIcon from "@mui/icons-material/Add"; import SubscribeDialog from "./SubscribeDialog"; -import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Portal, Tooltip} from "@mui/material"; +import { + Alert, + AlertTitle, + Badge, + CircularProgress, + Link, + ListSubheader, + Portal, + Tooltip, +} from "@mui/material"; import Button from "@mui/material/Button"; import Typography from "@mui/material/Typography"; -import {openUrl, topicDisplayName, topicUrl} from "../app/utils"; +import { openUrl, topicDisplayName, topicUrl } from "../app/utils"; import routes from "./routes"; -import {ConnectionState} from "../app/Connection"; -import {useLocation, useNavigate} from "react-router-dom"; +import { ConnectionState } from "../app/Connection"; +import { useLocation, useNavigate } from "react-router-dom"; import subscriptionManager from "../app/SubscriptionManager"; -import {ChatBubble, MoreVert, NotificationsOffOutlined, Send} from "@mui/icons-material"; +import { + ChatBubble, + MoreVert, + NotificationsOffOutlined, + Send, +} from "@mui/icons-material"; import Box from "@mui/material/Box"; import notifier from "../app/Notifier"; import config from "../app/config"; -import ArticleIcon from '@mui/icons-material/Article'; -import {Trans, useTranslation} from "react-i18next"; +import ArticleIcon from "@mui/icons-material/Article"; +import { Trans, useTranslation } from "react-i18next"; import session from "../app/Session"; -import accountApi, {Permission, Role} from "../app/AccountApi"; -import CelebrationIcon from '@mui/icons-material/Celebration'; +import accountApi, { Permission, Role } from "../app/AccountApi"; +import CelebrationIcon from "@mui/icons-material/Celebration"; import UpgradeDialog from "./UpgradeDialog"; -import {AccountContext} from "./App"; -import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; +import { AccountContext } from "./App"; +import { + PermissionDenyAll, + PermissionRead, + PermissionReadWrite, + PermissionWrite, +} from "./ReserveIcons"; import IconButton from "@mui/material/IconButton"; import { SubscriptionPopup } from "./SubscriptionPopup"; const navWidth = 280; const Navigation = (props) => { - const navigationList = ; - return ( - - {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */} - - {navigationList} - - {/* Big screen drawer; persistent, shown if screen is big */} - - {navigationList} - - - ); + const navigationList = ; + return ( + + {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */} + + {navigationList} + + {/* Big screen drawer; persistent, shown if screen is big */} + + {navigationList} + + + ); }; Navigation.width = navWidth; const NavList = (props) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const location = useLocation(); - const { account } = useContext(AccountContext); - const [subscribeDialogKey, setSubscribeDialogKey] = useState(0); - const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const { account } = useContext(AccountContext); + const [subscribeDialogKey, setSubscribeDialogKey] = useState(0); + const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false); - const handleSubscribeReset = () => { - setSubscribeDialogOpen(false); - setSubscribeDialogKey(prev => prev+1); - } + const handleSubscribeReset = () => { + setSubscribeDialogOpen(false); + setSubscribeDialogKey((prev) => prev + 1); + }; - const handleSubscribeSubmit = (subscription) => { - console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); - handleSubscribeReset(); - navigate(routes.forSubscription(subscription)); - handleRequestNotificationPermission(); - } - - const handleRequestNotificationPermission = () => { - notifier.maybeRequestPermission(granted => props.onNotificationGranted(granted)) - }; - - const handleAccountClick = () => { - accountApi.sync(); // Dangle! - navigate(routes.account); - }; - - const isAdmin = account?.role === Role.ADMIN; - const isPaid = account?.billing?.subscription; - const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid; - const showSubscriptionsList = props.subscriptions?.length > 0; - const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); - const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser - const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted; - const navListPadding = (showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox) ? '0' : ''; - - return ( - <> - - - {showNotificationBrowserNotSupportedBox && } - {showNotificationContextNotSupportedBox && } - {showNotificationGrantBox && } - {!showSubscriptionsList && - navigate(routes.app)} selected={location.pathname === config.app_root}> - - - } - {showSubscriptionsList && - <> - {t("nav_topics_title")} - navigate(routes.app)} selected={location.pathname === config.app_root}> - - - - - - } - {session.exists() && - - - - - } - navigate(routes.settings)} selected={location.pathname === routes.settings}> - - - - openUrl("/docs")}> - - - - props.onPublishMessageClick()}> - - - - setSubscribeDialogOpen(true)}> - - - - {showUpgradeBanner && - - } - - - + const handleSubscribeSubmit = (subscription) => { + console.log( + `[Navigation] New subscription: ${subscription.id}`, + subscription ); + handleSubscribeReset(); + navigate(routes.forSubscription(subscription)); + handleRequestNotificationPermission(); + }; + + const handleRequestNotificationPermission = () => { + notifier.maybeRequestPermission((granted) => + props.onNotificationGranted(granted) + ); + }; + + const handleAccountClick = () => { + accountApi.sync(); // Dangle! + navigate(routes.account); + }; + + const isAdmin = account?.role === Role.ADMIN; + const isPaid = account?.billing?.subscription; + const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid; + const showSubscriptionsList = props.subscriptions?.length > 0; + const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); + const showNotificationContextNotSupportedBox = + notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser + const showNotificationGrantBox = + notifier.supported() && + props.subscriptions?.length > 0 && + !props.notificationsGranted; + const navListPadding = + showNotificationGrantBox || + showNotificationBrowserNotSupportedBox || + showNotificationContextNotSupportedBox + ? "0" + : ""; + + return ( + <> + + + {showNotificationBrowserNotSupportedBox && ( + + )} + {showNotificationContextNotSupportedBox && ( + + )} + {showNotificationGrantBox && ( + + )} + {!showSubscriptionsList && ( + navigate(routes.app)} + selected={location.pathname === config.app_root} + > + + + + + + )} + {showSubscriptionsList && ( + <> + {t("nav_topics_title")} + navigate(routes.app)} + selected={location.pathname === config.app_root} + > + + + + + + + + + )} + {session.exists() && ( + + + + + + + )} + navigate(routes.settings)} + selected={location.pathname === routes.settings} + > + + + + + + openUrl("/docs")}> + + + + + + props.onPublishMessageClick()}> + + + + + + setSubscribeDialogOpen(true)}> + + + + + + {showUpgradeBanner && } + + + + ); }; const UpgradeBanner = () => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); - const handleClick = () => { - setDialogKey(k => k + 1); - setDialogOpen(true); - }; + const handleClick = () => { + setDialogKey((k) => k + 1); + setDialogOpen(true); + }; - return ( - - - - - - - setDialogOpen(false)} - /> - - ); + return ( + + + + + + + + + setDialogOpen(false)} + /> + + ); }; const SubscriptionList = (props) => { - const sortedSubscriptions = props.subscriptions - .filter(s => !s.internal) - .sort((a, b) => { - return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1; - }); - return ( - <> - {sortedSubscriptions.map(subscription => - )} - - ); -} + const sortedSubscriptions = props.subscriptions + .filter((s) => !s.internal) + .sort((a, b) => { + return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) + ? -1 + : 1; + }); + return ( + <> + {sortedSubscriptions.map((subscription) => ( + + ))} + + ); +}; const SubscriptionItem = (props) => { - const { t } = useTranslation(); - const navigate = useNavigate(); - const [menuAnchorEl, setMenuAnchorEl] = useState(null); + const { t } = useTranslation(); + const navigate = useNavigate(); + const [menuAnchorEl, setMenuAnchorEl] = useState(null); - const subscription = props.subscription; - const iconBadge = (subscription.new <= 99) ? subscription.new : "99+"; - const displayName = topicDisplayName(subscription); - const ariaLabel = (subscription.state === ConnectionState.Connecting) - ? `${displayName} (${t("nav_button_connecting")})` - : displayName; - const icon = (subscription.state === ConnectionState.Connecting) - ? - : ; - - const handleClick = async () => { - navigate(routes.forSubscription(subscription)); - await subscriptionManager.markNotificationsRead(subscription.id); - }; - - return ( - <> - - {icon} - - {subscription.reservation?.everyone && - - {subscription.reservation?.everyone === Permission.READ_WRITE && - - } - {subscription.reservation?.everyone === Permission.READ_ONLY && - - } - {subscription.reservation?.everyone === Permission.WRITE_ONLY && - - } - {subscription.reservation?.everyone === Permission.DENY_ALL && - - } - - } - {subscription.mutedUntil > 0 && - - - - } - - e.stopPropagation()} - onClick={(e) => { - e.stopPropagation(); - setMenuAnchorEl(e.currentTarget); - }} - > - - - - - - setMenuAnchorEl(null)} - /> - - + const subscription = props.subscription; + const iconBadge = subscription.new <= 99 ? subscription.new : "99+"; + const displayName = topicDisplayName(subscription); + const ariaLabel = + subscription.state === ConnectionState.Connecting + ? `${displayName} (${t("nav_button_connecting")})` + : displayName; + const icon = + subscription.state === ConnectionState.Connecting ? ( + + ) : ( + + + ); + + const handleClick = async () => { + navigate(routes.forSubscription(subscription)); + await subscriptionManager.markNotificationsRead(subscription.id); + }; + + return ( + <> + + {icon} + + {subscription.reservation?.everyone && ( + + {subscription.reservation?.everyone === Permission.READ_WRITE && ( + + + + )} + {subscription.reservation?.everyone === Permission.READ_ONLY && ( + + + + )} + {subscription.reservation?.everyone === Permission.WRITE_ONLY && ( + + + + )} + {subscription.reservation?.everyone === Permission.DENY_ALL && ( + + + + )} + + )} + {subscription.mutedUntil > 0 && ( + + + + + + )} + + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + setMenuAnchorEl(e.currentTarget); + }} + > + + + + + + setMenuAnchorEl(null)} + /> + + + ); }; const NotificationGrantAlert = (props) => { - const { t } = useTranslation(); - return ( - <> - - {t("alert_grant_title")} - {t("alert_grant_description")} - - - - - ); + const { t } = useTranslation(); + return ( + <> + + {t("alert_grant_title")} + {t("alert_grant_description")} + + + + + ); }; const NotificationBrowserNotSupportedAlert = () => { - const { t } = useTranslation(); - return ( - <> - - {t("alert_not_supported_title")} - {t("alert_not_supported_description")} - - - - ); + const { t } = useTranslation(); + return ( + <> + + {t("alert_not_supported_title")} + + {t("alert_not_supported_description")} + + + + + ); }; const NotificationContextNotSupportedAlert = () => { - const { t } = useTranslation(); - return ( - <> - - {t("alert_not_supported_title")} - - - }} - /> - - - - - ); + const { t } = useTranslation(); + return ( + <> + + {t("alert_not_supported_title")} + + + ), + }} + /> + + + + + ); }; export default Navigation; diff --git a/web/src/components/Notifications.js b/web/src/components/Notifications.js index 10bcad8..e55674b 100644 --- a/web/src/components/Notifications.js +++ b/web/src/components/Notifications.js @@ -1,36 +1,40 @@ import Container from "@mui/material/Container"; import { - ButtonBase, - CardActions, - CardContent, - CircularProgress, - Fade, - Link, - Modal, - Snackbar, - Stack, - Tooltip + ButtonBase, + CardActions, + CardContent, + CircularProgress, + Fade, + Link, + Modal, + Snackbar, + Stack, + Tooltip, } from "@mui/material"; import Card from "@mui/material/Card"; import Typography from "@mui/material/Typography"; import * as React from "react"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; import { - formatBytes, - formatMessage, - formatShortDateTime, - formatTitle, - maybeAppendActionErrors, - openUrl, - shortUrl, - topicShortUrl, - unmatchedTags + formatBytes, + formatMessage, + formatShortDateTime, + formatTitle, + maybeAppendActionErrors, + openUrl, + shortUrl, + topicShortUrl, + unmatchedTags, } from "../app/utils"; import IconButton from "@mui/material/IconButton"; -import CheckIcon from '@mui/icons-material/Check'; -import CloseIcon from '@mui/icons-material/Close'; -import {LightboxBackdrop, Paragraph, VerticallyCenteredContainer} from "./styles"; -import {useLiveQuery} from "dexie-react-hooks"; +import CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; +import { + LightboxBackdrop, + Paragraph, + VerticallyCenteredContainer, +} from "./styles"; +import { useLiveQuery } from "dexie-react-hooks"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import subscriptionManager from "../app/SubscriptionManager"; @@ -41,187 +45,263 @@ import priority4 from "../img/priority-4.svg"; import priority5 from "../img/priority-5.svg"; import logoOutline from "../img/ntfy-outline.svg"; import AttachmentIcon from "./AttachmentIcon"; -import {Trans, useTranslation} from "react-i18next"; -import {useOutletContext} from "react-router-dom"; -import {useAutoSubscribe} from "./hooks"; +import { Trans, useTranslation } from "react-i18next"; +import { useOutletContext } from "react-router-dom"; +import { useAutoSubscribe } from "./hooks"; export const AllSubscriptions = () => { - const { subscriptions } = useOutletContext(); - if (!subscriptions) { - return ; - } - return ; + const { subscriptions } = useOutletContext(); + if (!subscriptions) { + return ; + } + return ; }; export const SingleSubscription = () => { - const { subscriptions, selected } = useOutletContext(); - useAutoSubscribe(subscriptions, selected); - if (!selected) { - return ; - } - return ; + const { subscriptions, selected } = useOutletContext(); + useAutoSubscribe(subscriptions, selected); + if (!selected) { + return ; + } + return ; }; const AllSubscriptionsList = (props) => { - const subscriptions = props.subscriptions; - const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []); - if (notifications === null || notifications === undefined) { - return ; - } else if (subscriptions.length === 0) { - return ; - } else if (notifications.length === 0) { - return ; - } - return ; -} + const subscriptions = props.subscriptions; + const notifications = useLiveQuery( + () => subscriptionManager.getAllNotifications(), + [] + ); + if (notifications === null || notifications === undefined) { + return ; + } else if (subscriptions.length === 0) { + return ; + } else if (notifications.length === 0) { + return ; + } + return ( + + ); +}; const SingleSubscriptionList = (props) => { - const subscription = props.subscription; - const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]); - if (notifications === null || notifications === undefined) { - return ; - } else if (notifications.length === 0) { - return ; - } - return ; -} + const subscription = props.subscription; + const notifications = useLiveQuery( + () => subscriptionManager.getNotifications(subscription.id), + [subscription] + ); + if (notifications === null || notifications === undefined) { + return ; + } else if (notifications.length === 0) { + return ; + } + return ( + + ); +}; const NotificationList = (props) => { - const { t } = useTranslation(); - const pageSize = 20; - const notifications = props.notifications; - const [snackOpen, setSnackOpen] = useState(false); - const [maxCount, setMaxCount] = useState(pageSize); - const count = Math.min(notifications.length, maxCount); + const { t } = useTranslation(); + const pageSize = 20; + const notifications = props.notifications; + const [snackOpen, setSnackOpen] = useState(false); + const [maxCount, setMaxCount] = useState(pageSize); + const count = Math.min(notifications.length, maxCount); - useEffect(() => { - return () => { - setMaxCount(pageSize); - const main = document.getElementById("main"); - if (main) { - main.scrollTo(0, 0); - } - } - }, [props.id]); + useEffect(() => { + return () => { + setMaxCount(pageSize); + const main = document.getElementById("main"); + if (main) { + main.scrollTo(0, 0); + } + }; + }, [props.id]); - return ( - setMaxCount(prev => prev + pageSize)} - hasMore={count < notifications.length} - loader={<>Loading ...} - scrollThreshold={0.7} - scrollableTarget="main" - > - - - {notifications.slice(0, count).map(notification => - setSnackOpen(true)} - />)} - setSnackOpen(false)} - message={t("notifications_copied_to_clipboard")} - /> - - - - ); -} + return ( + setMaxCount((prev) => prev + pageSize)} + hasMore={count < notifications.length} + loader={<>Loading ...} + scrollThreshold={0.7} + scrollableTarget="main" + > + + + {notifications.slice(0, count).map((notification) => ( + setSnackOpen(true)} + /> + ))} + setSnackOpen(false)} + message={t("notifications_copied_to_clipboard")} + /> + + + + ); +}; const NotificationItem = (props) => { - const { t } = useTranslation(); - const notification = props.notification; - const attachment = notification.attachment; - const date = formatShortDateTime(notification.time); - const otherTags = unmatchedTags(notification.tags); - const tags = (otherTags.length > 0) ? otherTags.join(', ') : null; - const handleDelete = async () => { - console.log(`[Notifications] Deleting notification ${notification.id}`); - await subscriptionManager.deleteNotification(notification.id) - } - const handleMarkRead = async () => { - console.log(`[Notifications] Marking notification ${notification.id} as read`); - await subscriptionManager.markNotificationRead(notification.id) - } - const handleCopy = (s) => { - navigator.clipboard.writeText(s); - props.onShowSnack(); - }; - const expired = attachment && attachment.expires && attachment.expires < Date.now()/1000; - const hasAttachmentActions = attachment && !expired; - const hasClickAction = notification.click; - const hasUserActions = notification.actions && notification.actions.length > 0; - const showActions = hasAttachmentActions || hasClickAction || hasUserActions; - return ( - - - - - - - - {notification.new === 1 && - - - - - } - - {date} - {[1,2,4,5].includes(notification.priority) && - {t("notifications_priority_x",} - {notification.new === 1 && - - - } - - {notification.title && {formatTitle(notification)}} - - {autolink(maybeAppendActionErrors(formatMessage(notification), notification))} - - {attachment && } - {tags && {t("notifications_tags")}: {tags}} - - {showActions && - - {hasAttachmentActions && <> - - - - - - - } - {hasClickAction && <> - - - - - - - } - {hasUserActions && } - } - + const { t } = useTranslation(); + const notification = props.notification; + const attachment = notification.attachment; + const date = formatShortDateTime(notification.time); + const otherTags = unmatchedTags(notification.tags); + const tags = otherTags.length > 0 ? otherTags.join(", ") : null; + const handleDelete = async () => { + console.log(`[Notifications] Deleting notification ${notification.id}`); + await subscriptionManager.deleteNotification(notification.id); + }; + const handleMarkRead = async () => { + console.log( + `[Notifications] Marking notification ${notification.id} as read` ); -} + await subscriptionManager.markNotificationRead(notification.id); + }; + const handleCopy = (s) => { + navigator.clipboard.writeText(s); + props.onShowSnack(); + }; + const expired = + attachment && attachment.expires && attachment.expires < Date.now() / 1000; + const hasAttachmentActions = attachment && !expired; + const hasClickAction = notification.click; + const hasUserActions = + notification.actions && notification.actions.length > 0; + const showActions = hasAttachmentActions || hasClickAction || hasUserActions; + return ( + + + + + + + + {notification.new === 1 && ( + + + + + + )} + + {date} + {[1, 2, 4, 5].includes(notification.priority) && ( + {t("notifications_priority_x", + )} + {notification.new === 1 && ( + + + + )} + + {notification.title && ( + + {formatTitle(notification)} + + )} + + {autolink( + maybeAppendActionErrors(formatMessage(notification), notification) + )} + + {attachment && } + {tags && ( + + {t("notifications_tags")}: {tags} + + )} + + {showActions && ( + + {hasAttachmentActions && ( + <> + + + + + + + + )} + {hasClickAction && ( + <> + + + + + + + + )} + {hasUserActions && } + + )} + + ); +}; /** * Replace links with components; this is a combination of the genius function @@ -231,318 +311,415 @@ const NotificationItem = (props) => { * [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9 */ const autolink = (s) => { - const parts = s.split(/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi); - for (let i = 1; i < parts.length; i += 2) { - parts[i] = {shortUrl(parts[i])}; - } - return <>{parts}; + const parts = s.split( + /(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi + ); + for (let i = 1; i < parts.length; i += 2) { + parts[i] = ( + + {shortUrl(parts[i])} + + ); + } + return <>{parts}; }; const priorityFiles = { - 1: priority1, - 2: priority2, - 4: priority4, - 5: priority5 + 1: priority1, + 2: priority2, + 4: priority4, + 5: priority5, }; const Attachment = (props) => { - const { t } = useTranslation(); - const attachment = props.attachment; - const expired = attachment.expires && attachment.expires < Date.now()/1000; - const expires = attachment.expires && attachment.expires > Date.now()/1000; - const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/"); + const { t } = useTranslation(); + const attachment = props.attachment; + const expired = attachment.expires && attachment.expires < Date.now() / 1000; + const expires = attachment.expires && attachment.expires > Date.now() / 1000; + const displayableImage = + !expired && attachment.type && attachment.type.startsWith("image/"); - // Unexpired image - if (displayableImage) { - return ; - } + // Unexpired image + if (displayableImage) { + return ; + } - // Anything else: Show box - const infos = []; - if (attachment.size) { - infos.push(formatBytes(attachment.size)); - } - if (expires) { - infos.push(t("notifications_attachment_link_expires", { date: formatShortDateTime(attachment.expires) })); - } - if (expired) { - infos.push(t("notifications_attachment_link_expired")); - } - const maybeInfoText = (infos.length > 0) ? <>
{infos.join(", ")} : null; - - // If expired, just show infos without click target - if (expired) { - return ( - - - - {attachment.name} - {maybeInfoText} - - - ); - } - - // Not expired - return ( - - - - - {attachment.name} - {maybeInfoText} - - - + // Anything else: Show box + const infos = []; + if (attachment.size) { + infos.push(formatBytes(attachment.size)); + } + if (expires) { + infos.push( + t("notifications_attachment_link_expires", { + date: formatShortDateTime(attachment.expires), + }) ); + } + if (expired) { + infos.push(t("notifications_attachment_link_expired")); + } + const maybeInfoText = + infos.length > 0 ? ( + <> +
+ {infos.join(", ")} + + ) : null; + + // If expired, just show infos without click target + if (expired) { + return ( + + + + {attachment.name} + {maybeInfoText} + + + ); + } + + // Not expired + return ( + + + + + {attachment.name} + {maybeInfoText} + + + + ); }; const Image = (props) => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - return ( - <> - setOpen(true)} - sx={{ - marginTop: 2, - borderRadius: '4px', - boxShadow: 2, - width: 1, - maxHeight: '400px', - objectFit: 'cover', - cursor: 'pointer' - }} - /> - setOpen(false)} - BackdropComponent={LightboxBackdrop} - > - - - - - - ); -} + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + return ( + <> + setOpen(true)} + sx={{ + marginTop: 2, + borderRadius: "4px", + boxShadow: 2, + width: 1, + maxHeight: "400px", + objectFit: "cover", + cursor: "pointer", + }} + /> + setOpen(false)} + BackdropComponent={LightboxBackdrop} + > + + + + + + ); +}; const UserActions = (props) => { - return ( - <>{props.notification.actions.map(action => - )} - ); + return ( + <> + {props.notification.actions.map((action) => ( + + ))} + + ); }; const UserAction = (props) => { - const { t } = useTranslation(); - const notification = props.notification; - const action = props.action; - if (action.action === "broadcast") { - return ( - - - - ); - } else if (action.action === "view") { - return ( - - - - ); - } else if (action.action === "http") { - const method = action.method ?? "POST"; - const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? ""); - return ( - - - - ); - } - return null; // Others + const { t } = useTranslation(); + const notification = props.notification; + const action = props.action; + if (action.action === "broadcast") { + return ( + + + + + + ); + } else if (action.action === "view") { + return ( + + + + ); + } else if (action.action === "http") { + const method = action.method ?? "POST"; + const label = + action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? ""); + return ( + + + + ); + } + return null; // Others }; const performHttpAction = async (notification, action) => { - console.log(`[Notifications] Performing HTTP user action`, action); - try { - updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null); - const response = await fetch(action.url, { - method: action.method ?? "POST", - headers: action.headers ?? {}, - // This must not null-coalesce to a non nullish value. Otherwise, the fetch API - // will reject it for "having a body" - body: action.body - }); - console.log(`[Notifications] HTTP user action response`, response); - const success = response.status >= 200 && response.status <= 299; - if (success) { - updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null); - } else { - updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: Unexpected response HTTP ${response.status}`); - } - } catch (e) { - console.log(`[Notifications] HTTP action failed`, e); - updateActionStatus(notification, action, ACTION_PROGRESS_FAILED, `${action.label}: ${e} Check developer console for details.`); + console.log(`[Notifications] Performing HTTP user action`, action); + try { + updateActionStatus(notification, action, ACTION_PROGRESS_ONGOING, null); + const response = await fetch(action.url, { + method: action.method ?? "POST", + headers: action.headers ?? {}, + // This must not null-coalesce to a non nullish value. Otherwise, the fetch API + // will reject it for "having a body" + body: action.body, + }); + console.log(`[Notifications] HTTP user action response`, response); + const success = response.status >= 200 && response.status <= 299; + if (success) { + updateActionStatus(notification, action, ACTION_PROGRESS_SUCCESS, null); + } else { + updateActionStatus( + notification, + action, + ACTION_PROGRESS_FAILED, + `${action.label}: Unexpected response HTTP ${response.status}` + ); } + } catch (e) { + console.log(`[Notifications] HTTP action failed`, e); + updateActionStatus( + notification, + action, + ACTION_PROGRESS_FAILED, + `${action.label}: ${e} Check developer console for details.` + ); + } }; const updateActionStatus = (notification, action, progress, error) => { - notification.actions = notification.actions.map(a => { - if (a.id !== action.id) { - return a; - } - return { ...a, progress: progress, error: error }; - }); - subscriptionManager.updateNotification(notification); -} + notification.actions = notification.actions.map((a) => { + if (a.id !== action.id) { + return a; + } + return { ...a, progress: progress, error: error }; + }); + subscriptionManager.updateNotification(notification); +}; const ACTION_PROGRESS_ONGOING = 1; const ACTION_PROGRESS_SUCCESS = 2; const ACTION_PROGRESS_FAILED = 3; const ACTION_LABEL_SUFFIX = { - [ACTION_PROGRESS_ONGOING]: " …", - [ACTION_PROGRESS_SUCCESS]: " ✔", - [ACTION_PROGRESS_FAILED]: " ❌" + [ACTION_PROGRESS_ONGOING]: " …", + [ACTION_PROGRESS_SUCCESS]: " ✔", + [ACTION_PROGRESS_FAILED]: " ❌", }; const NoNotifications = (props) => { - const { t } = useTranslation(); - const shortUrl = topicShortUrl(props.subscription.baseUrl, props.subscription.topic); - return ( - - - {t("action_bar_logo_alt")}/
- {t("notifications_none_for_topic_title")} -
- - {t("notifications_none_for_topic_description")} - - - {t("notifications_example")}:
- - $ curl -d "Hi" {shortUrl} - -
- - - -
- ); + const { t } = useTranslation(); + const shortUrl = topicShortUrl( + props.subscription.baseUrl, + props.subscription.topic + ); + return ( + + + {t("action_bar_logo_alt")} +
+ {t("notifications_none_for_topic_title")} +
+ {t("notifications_none_for_topic_description")} + + {t("notifications_example")}:
+ $ curl -d "Hi" {shortUrl} +
+ + + +
+ ); }; const NoNotificationsWithoutSubscription = (props) => { - const { t } = useTranslation(); - const subscription = props.subscriptions[0]; - const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); - return ( - - - {t("action_bar_logo_alt")}/
- {t("notifications_none_for_any_title")} -
- - {t("notifications_none_for_any_description")} - - - {t("notifications_example")}:
- - $ curl -d "Hi" {shortUrl} - -
- - - -
- ); + const { t } = useTranslation(); + const subscription = props.subscriptions[0]; + const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic); + return ( + + + {t("action_bar_logo_alt")} +
+ {t("notifications_none_for_any_title")} +
+ {t("notifications_none_for_any_description")} + + {t("notifications_example")}:
+ $ curl -d "Hi" {shortUrl} +
+ + + +
+ ); }; const NoSubscriptions = () => { - const { t } = useTranslation(); - return ( - - - {t("action_bar_logo_alt")}/
- {t("notifications_no_subscriptions_title")} -
- - {t("notifications_no_subscriptions_description", { - linktext: t("nav_button_subscribe") - })} - - - - -
- ); + const { t } = useTranslation(); + return ( + + + {t("action_bar_logo_alt")} +
+ {t("notifications_no_subscriptions_title")} +
+ + {t("notifications_no_subscriptions_description", { + linktext: t("nav_button_subscribe"), + })} + + + + +
+ ); }; const ForMoreDetails = () => { - return ( - , - docsLink: - }} - /> - ); + return ( + + ), + docsLink: ( + + ), + }} + /> + ); }; const Loading = () => { - const { t } = useTranslation(); - return ( - - -
- {t("notifications_loading")} -
-
- ); + const { t } = useTranslation(); + return ( + + + +
+ {t("notifications_loading")} +
+
+ ); }; diff --git a/web/src/components/PopupMenu.js b/web/src/components/PopupMenu.js index 4d22398..501f86a 100644 --- a/web/src/components/PopupMenu.js +++ b/web/src/components/PopupMenu.js @@ -1,48 +1,48 @@ -import {Fade, Menu} from "@mui/material"; +import { Fade, Menu } from "@mui/material"; import * as React from "react"; const PopupMenu = (props) => { - const horizontal = props.horizontal ?? "left"; - const arrow = (horizontal === "right") ? { right: 19 } : { left: 19 }; - return ( - - {props.children} - - ); + const horizontal = props.horizontal ?? "left"; + const arrow = horizontal === "right" ? { right: 19 } : { left: 19 }; + return ( + + {props.children} + + ); }; export default PopupMenu; diff --git a/web/src/components/Pref.js b/web/src/components/Pref.js index 622d9bb..07052f6 100644 --- a/web/src/components/Pref.js +++ b/web/src/components/Pref.js @@ -1,51 +1,54 @@ import * as React from "react"; export const PrefGroup = (props) => { - return ( -
- {props.children} -
- ) + return
{props.children}
; }; export const Pref = (props) => { - const justifyContent = (props.alignTop) ? "normal" : "center"; - return ( -
-
-
{props.title}{props.subtitle && ({props.subtitle})}
- {props.description &&
{props.description}
} -
-
- {props.children} -
+ const justifyContent = props.alignTop ? "normal" : "center"; + return ( +
+
+
+ {props.title} + {props.subtitle && ({props.subtitle})}
- ); + {props.description && ( +
+ {props.description} +
+ )} +
+
+ {props.children} +
+
+ ); }; diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index fc8cb35..cbbf1a8 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -1,654 +1,832 @@ -import * as React from 'react'; -import {useContext, useEffect, useState} from 'react'; +import * as React from "react"; +import { useContext, useEffect, useState } from "react"; import { - Alert, - CardActions, - CardContent, - Chip, - FormControl, - Select, - Stack, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - Tooltip, - useMediaQuery + Alert, + CardActions, + CardContent, + Chip, + FormControl, + Select, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + useMediaQuery, } from "@mui/material"; import Typography from "@mui/material/Typography"; import prefs from "../app/Prefs"; -import {Paragraph} from "./styles"; -import EditIcon from '@mui/icons-material/Edit'; +import { Paragraph } from "./styles"; +import EditIcon from "@mui/icons-material/Edit"; import CloseIcon from "@mui/icons-material/Close"; import IconButton from "@mui/material/IconButton"; -import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import Container from "@mui/material/Container"; import TextField from "@mui/material/TextField"; import MenuItem from "@mui/material/MenuItem"; import Card from "@mui/material/Card"; import Button from "@mui/material/Button"; -import {useLiveQuery} from "dexie-react-hooks"; +import { useLiveQuery } from "dexie-react-hooks"; import theme from "./theme"; import Dialog from "@mui/material/Dialog"; import DialogTitle from "@mui/material/DialogTitle"; import DialogContent from "@mui/material/DialogContent"; import DialogActions from "@mui/material/DialogActions"; import userManager from "../app/UserManager"; -import {playSound, shuffle, sounds, validUrl} from "../app/utils"; -import {useTranslation} from "react-i18next"; +import { playSound, shuffle, sounds, validUrl } from "../app/utils"; +import { useTranslation } from "react-i18next"; import session from "../app/Session"; import routes from "./routes"; -import accountApi, {Permission, Role} from "../app/AccountApi"; -import {Pref, PrefGroup} from "./Pref"; -import {Info} from "@mui/icons-material"; -import {AccountContext} from "./App"; -import {useOutletContext} from "react-router-dom"; -import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; -import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs"; -import {UnauthorizedError} from "../app/errors"; +import accountApi, { Permission, Role } from "../app/AccountApi"; +import { Pref, PrefGroup } from "./Pref"; +import { Info } from "@mui/icons-material"; +import { AccountContext } from "./App"; +import { useOutletContext } from "react-router-dom"; +import { + PermissionDenyAll, + PermissionRead, + PermissionReadWrite, + PermissionWrite, +} from "./ReserveIcons"; +import { + ReserveAddDialog, + ReserveDeleteDialog, + ReserveEditDialog, +} from "./ReserveDialogs"; +import { UnauthorizedError } from "../app/errors"; import subscriptionManager from "../app/SubscriptionManager"; -import {subscribeTopic} from "./SubscribeDialog"; +import { subscribeTopic } from "./SubscribeDialog"; const Preferences = () => { - return ( - - - - - - - - - ); + return ( + + + + + + + + + ); }; const Notifications = () => { - const { t } = useTranslation(); - return ( - - - {t("prefs_notifications_title")} - - - - - - - - ); + const { t } = useTranslation(); + return ( + + + {t("prefs_notifications_title")} + + + + + + + + ); }; const Sound = () => { - const { t } = useTranslation(); - const labelId = "prefSound"; - const sound = useLiveQuery(async () => prefs.sound()); - const handleChange = async (ev) => { - await prefs.setSound(ev.target.value); - await maybeUpdateAccountSettings({ - notification: { - sound: ev.target.value - } - }); - } - if (!sound) { - return null; // While loading - } - let description; - if (sound === "none") { - description = t("prefs_notifications_sound_description_none"); - } else { - description = t("prefs_notifications_sound_description_some", { sound: sounds[sound].label }); - } - return ( - -
- - - - playSound(sound)} disabled={sound === "none"} aria-label={t("prefs_notifications_sound_play")}> - - -
-
- ) + const { t } = useTranslation(); + const labelId = "prefSound"; + const sound = useLiveQuery(async () => prefs.sound()); + const handleChange = async (ev) => { + await prefs.setSound(ev.target.value); + await maybeUpdateAccountSettings({ + notification: { + sound: ev.target.value, + }, + }); + }; + if (!sound) { + return null; // While loading + } + let description; + if (sound === "none") { + description = t("prefs_notifications_sound_description_none"); + } else { + description = t("prefs_notifications_sound_description_some", { + sound: sounds[sound].label, + }); + } + return ( + +
+ + + + playSound(sound)} + disabled={sound === "none"} + aria-label={t("prefs_notifications_sound_play")} + > + + +
+
+ ); }; const MinPriority = () => { - const { t } = useTranslation(); - const labelId = "prefMinPriority"; - const minPriority = useLiveQuery(async () => prefs.minPriority()); - const handleChange = async (ev) => { - await prefs.setMinPriority(ev.target.value); - await maybeUpdateAccountSettings({ - notification: { - min_priority: ev.target.value - } - }); - } - if (!minPriority) { - return null; // While loading - } - const priorities = { - 1: t("priority_min"), - 2: t("priority_low"), - 3: t("priority_default"), - 4: t("priority_high"), - 5: t("priority_max") - } - let description; - if (minPriority === 1) { - description = t("prefs_notifications_min_priority_description_any"); - } else if (minPriority === 5) { - description = t("prefs_notifications_min_priority_description_max"); - } else { - description = t("prefs_notifications_min_priority_description_x_or_higher", { - number: minPriority, - name: priorities[minPriority] - }); - } - return ( - - - - - - ) + const { t } = useTranslation(); + const labelId = "prefMinPriority"; + const minPriority = useLiveQuery(async () => prefs.minPriority()); + const handleChange = async (ev) => { + await prefs.setMinPriority(ev.target.value); + await maybeUpdateAccountSettings({ + notification: { + min_priority: ev.target.value, + }, + }); + }; + if (!minPriority) { + return null; // While loading + } + const priorities = { + 1: t("priority_min"), + 2: t("priority_low"), + 3: t("priority_default"), + 4: t("priority_high"), + 5: t("priority_max"), + }; + let description; + if (minPriority === 1) { + description = t("prefs_notifications_min_priority_description_any"); + } else if (minPriority === 5) { + description = t("prefs_notifications_min_priority_description_max"); + } else { + description = t( + "prefs_notifications_min_priority_description_x_or_higher", + { + number: minPriority, + name: priorities[minPriority], + } + ); + } + return ( + + + + + + ); }; const DeleteAfter = () => { - const { t } = useTranslation(); - const labelId = "prefDeleteAfter"; - const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); - const handleChange = async (ev) => { - await prefs.setDeleteAfter(ev.target.value); - await maybeUpdateAccountSettings({ - notification: { - delete_after: ev.target.value - } - }); + const { t } = useTranslation(); + const labelId = "prefDeleteAfter"; + const deleteAfter = useLiveQuery(async () => prefs.deleteAfter()); + const handleChange = async (ev) => { + await prefs.setDeleteAfter(ev.target.value); + await maybeUpdateAccountSettings({ + notification: { + delete_after: ev.target.value, + }, + }); + }; + if (deleteAfter === null || deleteAfter === undefined) { + // !deleteAfter will not work with "0" + return null; // While loading + } + const description = (() => { + switch (deleteAfter) { + case 0: + return t("prefs_notifications_delete_after_never_description"); + case 10800: + return t("prefs_notifications_delete_after_three_hours_description"); + case 86400: + return t("prefs_notifications_delete_after_one_day_description"); + case 604800: + return t("prefs_notifications_delete_after_one_week_description"); + case 2592000: + return t("prefs_notifications_delete_after_one_month_description"); } - if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0" - return null; // While loading - } - const description = (() => { - switch (deleteAfter) { - case 0: return t("prefs_notifications_delete_after_never_description"); - case 10800: return t("prefs_notifications_delete_after_three_hours_description"); - case 86400: return t("prefs_notifications_delete_after_one_day_description"); - case 604800: return t("prefs_notifications_delete_after_one_week_description"); - case 2592000: return t("prefs_notifications_delete_after_one_month_description"); - } - })(); - return ( - - - - - - ) + })(); + return ( + + + + + + ); }; const Users = () => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - const users = useLiveQuery(() => userManager.all()); - const handleAddClick = () => { - setDialogKey(prev => prev+1); - setDialogOpen(true); - }; - const handleDialogCancel = () => { - setDialogOpen(false); - }; - const handleDialogSubmit = async (user) => { - setDialogOpen(false); - try { - await userManager.save(user); - console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} added`); - } catch (e) { - console.log(`[Preferences] Error adding user.`, e); - } - }; - return ( - - - - {t("prefs_users_title")} - - - {t("prefs_users_description")} - {session.exists() && <>{" " + t("prefs_users_description_no_sync")}} - - {users?.length > 0 && } - - - - - - - ); + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const users = useLiveQuery(() => userManager.all()); + const handleAddClick = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; + const handleDialogCancel = () => { + setDialogOpen(false); + }; + const handleDialogSubmit = async (user) => { + setDialogOpen(false); + try { + await userManager.save(user); + console.debug( + `[Preferences] User ${user.username} for ${user.baseUrl} added` + ); + } catch (e) { + console.log(`[Preferences] Error adding user.`, e); + } + }; + return ( + + + + {t("prefs_users_title")} + + + {t("prefs_users_description")} + {session.exists() && ( + <>{" " + t("prefs_users_description_no_sync")} + )} + + {users?.length > 0 && } + + + + + + + ); }; const UserTable = (props) => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - const [dialogUser, setDialogUser] = useState(null); + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogUser, setDialogUser] = useState(null); - const handleEditClick = (user) => { - setDialogKey(prev => prev+1); - setDialogUser(user); - setDialogOpen(true); - }; + const handleEditClick = (user) => { + setDialogKey((prev) => prev + 1); + setDialogUser(user); + setDialogOpen(true); + }; - const handleDialogCancel = () => { - setDialogOpen(false); - }; + const handleDialogCancel = () => { + setDialogOpen(false); + }; - const handleDialogSubmit = async (user) => { - setDialogOpen(false); - try { - await userManager.save(user); - console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`); - } catch (e) { - console.log(`[Preferences] Error updating user.`, e); - } - }; + const handleDialogSubmit = async (user) => { + setDialogOpen(false); + try { + await userManager.save(user); + console.debug( + `[Preferences] User ${user.username} for ${user.baseUrl} updated` + ); + } catch (e) { + console.log(`[Preferences] Error updating user.`, e); + } + }; - const handleDeleteClick = async (user) => { - try { - await userManager.delete(user.baseUrl); - console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`); - } catch (e) { - console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e); - } - }; + const handleDeleteClick = async (user) => { + try { + await userManager.delete(user.baseUrl); + console.debug( + `[Preferences] User ${user.username} for ${user.baseUrl} deleted` + ); + } catch (e) { + console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e); + } + }; - return ( - - - - {t("prefs_users_table_user_header")} - {t("prefs_users_table_base_url_header")} - - - - - {props.users?.map(user => ( - - {user.username} - {user.baseUrl} - - {(!session.exists() || user.baseUrl !== config.base_url) && - <> - handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> - - - handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> - - - - } - {session.exists() && user.baseUrl === config.base_url && - - - - - - - } - - - ))} - - -
- ); + return ( + + + + + {t("prefs_users_table_user_header")} + + {t("prefs_users_table_base_url_header")} + + + + + {props.users?.map((user) => ( + + + {user.username} + + + {user.baseUrl} + + + {(!session.exists() || user.baseUrl !== config.base_url) && ( + <> + handleEditClick(user)} + aria-label={t("prefs_users_edit_button")} + > + + + handleDeleteClick(user)} + aria-label={t("prefs_users_delete_button")} + > + + + + )} + {session.exists() && user.baseUrl === config.base_url && ( + + + + + + + + + + + )} + + + ))} + + +
+ ); }; const UserDialog = (props) => { - const { t } = useTranslation(); - const [baseUrl, setBaseUrl] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - const editMode = props.user !== null; - const addButtonEnabled = (() => { - if (editMode) { - return username.length > 0 && password.length > 0; - } - const baseUrlValid = validUrl(baseUrl); - const baseUrlExists = props.users?.map(user => user.baseUrl).includes(baseUrl); - return baseUrlValid - && !baseUrlExists - && username.length > 0 - && password.length > 0; - })(); - const handleSubmit = async () => { - props.onSubmit({ - baseUrl: baseUrl, - username: username, - password: password - }) - }; - useEffect(() => { - if (editMode) { - setBaseUrl(props.user.baseUrl); - setUsername(props.user.username); - setPassword(props.user.password); - } - }, [editMode, props.user]); + const { t } = useTranslation(); + const [baseUrl, setBaseUrl] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + const editMode = props.user !== null; + const addButtonEnabled = (() => { + if (editMode) { + return username.length > 0 && password.length > 0; + } + const baseUrlValid = validUrl(baseUrl); + const baseUrlExists = props.users + ?.map((user) => user.baseUrl) + .includes(baseUrl); return ( - - {editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")} - - {!editMode && setBaseUrl(ev.target.value)} - type="url" - fullWidth - variant="standard" - />} - setUsername(ev.target.value)} - type="text" - fullWidth - variant="standard" - /> - setPassword(ev.target.value)} - fullWidth - variant="standard" - /> - - - - - - + baseUrlValid && + !baseUrlExists && + username.length > 0 && + password.length > 0 ); + })(); + const handleSubmit = async () => { + props.onSubmit({ + baseUrl: baseUrl, + username: username, + password: password, + }); + }; + useEffect(() => { + if (editMode) { + setBaseUrl(props.user.baseUrl); + setUsername(props.user.username); + setPassword(props.user.password); + } + }, [editMode, props.user]); + return ( + + + {editMode + ? t("prefs_users_dialog_title_edit") + : t("prefs_users_dialog_title_add")} + + + {!editMode && ( + setBaseUrl(ev.target.value)} + type="url" + fullWidth + variant="standard" + /> + )} + setUsername(ev.target.value)} + type="text" + fullWidth + variant="standard" + /> + setPassword(ev.target.value)} + fullWidth + variant="standard" + /> + + + + + + + ); }; const Appearance = () => { - const { t } = useTranslation(); - return ( - - - {t("prefs_appearance_title")} - - - - - - ); + const { t } = useTranslation(); + return ( + + + {t("prefs_appearance_title")} + + + + + + ); }; const Language = () => { - const { t, i18n } = useTranslation(); - const labelId = "prefLanguage"; - const lang = i18n.resolvedLanguage ?? "en"; + const { t, i18n } = useTranslation(); + const labelId = "prefLanguage"; + const lang = i18n.resolvedLanguage ?? "en"; - // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts. - // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows. - const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇵🇱", "🇺🇦", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3); - const showFlags = !navigator.userAgent.includes("Windows"); - let title = t("prefs_appearance_language_title"); - if (showFlags) { - title += " " + randomFlags.join(" "); - } + // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts. + // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows. + const randomFlags = shuffle([ + "🇬🇧", + "🇺🇸", + "🇪🇸", + "🇫🇷", + "🇧🇬", + "🇨🇿", + "🇩🇪", + "🇵🇱", + "🇺🇦", + "🇨🇳", + "🇮🇹", + "🇭🇺", + "🇧🇷", + "🇳🇱", + "🇮🇩", + "🇯🇵", + "🇷🇺", + "🇹🇷", + ]).slice(0, 3); + const showFlags = !navigator.userAgent.includes("Windows"); + let title = t("prefs_appearance_language_title"); + if (showFlags) { + title += " " + randomFlags.join(" "); + } - const handleChange = async (ev) => { - await i18n.changeLanguage(ev.target.value); - await maybeUpdateAccountSettings({ - language: ev.target.value - }); - }; + const handleChange = async (ev) => { + await i18n.changeLanguage(ev.target.value); + await maybeUpdateAccountSettings({ + language: ev.target.value, + }); + }; - // Remember: Flags are not languages. Don't put flags next to the language in the list. - // Languages names from: https://www.omniglot.com/language/names.htm - // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l + // Remember: Flags are not languages. Don't put flags next to the language in the list. + // Languages names from: https://www.omniglot.com/language/names.htm + // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l - return ( - - - - - - ) + return ( + + + + + + ); }; const Reservations = () => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [dialogKey, setDialogKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [dialogKey, setDialogKey] = useState(0); + const [dialogOpen, setDialogOpen] = useState(false); - if (!config.enable_reservations || !session.exists() || !account) { - return <>; - } - const reservations = account.reservations || []; - const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0; + if (!config.enable_reservations || !session.exists() || !account) { + return <>; + } + const reservations = account.reservations || []; + const limitReached = + account.role === Role.USER && account.stats.reservations_remaining === 0; - const handleAddClick = () => { - setDialogKey(prev => prev+1); - setDialogOpen(true); - }; + const handleAddClick = () => { + setDialogKey((prev) => prev + 1); + setDialogOpen(true); + }; - return ( - - - - {t("prefs_reservations_title")} - - - {t("prefs_reservations_description")} - - {reservations.length > 0 && } - {limitReached && {t("prefs_reservations_limit_reached")}} - - - - setDialogOpen(false)} - /> - - - ); + return ( + + + + {t("prefs_reservations_title")} + + {t("prefs_reservations_description")} + {reservations.length > 0 && ( + + )} + {limitReached && ( + {t("prefs_reservations_limit_reached")} + )} + + + + setDialogOpen(false)} + /> + + + ); }; const ReservationsTable = (props) => { - const { t } = useTranslation(); - const [dialogKey, setDialogKey] = useState(0); - const [dialogReservation, setDialogReservation] = useState(null); - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const { subscriptions } = useOutletContext(); - const localSubscriptions = (subscriptions?.length > 0) - ? Object.assign({}, ...subscriptions.filter(s => s.baseUrl === config.base_url).map(s => ({[s.topic]: s}))) - : {}; + const { t } = useTranslation(); + const [dialogKey, setDialogKey] = useState(0); + const [dialogReservation, setDialogReservation] = useState(null); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const { subscriptions } = useOutletContext(); + const localSubscriptions = + subscriptions?.length > 0 + ? Object.assign( + {}, + ...subscriptions + .filter((s) => s.baseUrl === config.base_url) + .map((s) => ({ [s.topic]: s })) + ) + : {}; - const handleEditClick = (reservation) => { - setDialogKey(prev => prev+1); - setDialogReservation(reservation); - setEditDialogOpen(true); - }; + const handleEditClick = (reservation) => { + setDialogKey((prev) => prev + 1); + setDialogReservation(reservation); + setEditDialogOpen(true); + }; - const handleDeleteClick = async (reservation) => { - setDialogKey(prev => prev+1); - setDialogReservation(reservation); - setDeleteDialogOpen(true); - }; + const handleDeleteClick = async (reservation) => { + setDialogKey((prev) => prev + 1); + setDialogReservation(reservation); + setDeleteDialogOpen(true); + }; - const handleSubscribeClick = async (reservation) => { - await subscribeTopic(config.base_url, reservation.topic); - }; + const handleSubscribeClick = async (reservation) => { + await subscribeTopic(config.base_url, reservation.topic); + }; - return ( - - - - {t("prefs_reservations_table_topic_header")} - {t("prefs_reservations_table_access_header")} - - - - - {props.reservations.map(reservation => ( - - - {reservation.topic} - - - {reservation.everyone === Permission.READ_WRITE && - <> - - {t("prefs_reservations_table_everyone_read_write")} - - } - {reservation.everyone === Permission.READ_ONLY && - <> - - {t("prefs_reservations_table_everyone_read_only")} - - } - {reservation.everyone === Permission.WRITE_ONLY && - <> - - {t("prefs_reservations_table_everyone_write_only")} - - } - {reservation.everyone === Permission.DENY_ALL && - <> - - {t("prefs_reservations_table_everyone_deny_all")} - - } - - - {!localSubscriptions[reservation.topic] && - - } onClick={() => handleSubscribeClick(reservation)} label={t("prefs_reservations_table_not_subscribed")} color="primary" variant="outlined"/> - - } - handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> - - - handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}> - - - - - ))} - - setEditDialogOpen(false)} - /> - setDeleteDialogOpen(false)} - /> -
- ); + return ( + + + + + {t("prefs_reservations_table_topic_header")} + + {t("prefs_reservations_table_access_header")} + + + + + {props.reservations.map((reservation) => ( + + + {reservation.topic} + + + {reservation.everyone === Permission.READ_WRITE && ( + <> + + {t("prefs_reservations_table_everyone_read_write")} + + )} + {reservation.everyone === Permission.READ_ONLY && ( + <> + + {t("prefs_reservations_table_everyone_read_only")} + + )} + {reservation.everyone === Permission.WRITE_ONLY && ( + <> + + {t("prefs_reservations_table_everyone_write_only")} + + )} + {reservation.everyone === Permission.DENY_ALL && ( + <> + + {t("prefs_reservations_table_everyone_deny_all")} + + )} + + + {!localSubscriptions[reservation.topic] && ( + + } + onClick={() => handleSubscribeClick(reservation)} + label={t("prefs_reservations_table_not_subscribed")} + color="primary" + variant="outlined" + /> + + )} + handleEditClick(reservation)} + aria-label={t("prefs_reservations_edit_button")} + > + + + handleDeleteClick(reservation)} + aria-label={t("prefs_reservations_delete_button")} + > + + + + + ))} + + setEditDialogOpen(false)} + /> + setDeleteDialogOpen(false)} + /> +
+ ); }; const maybeUpdateAccountSettings = async (payload) => { - if (!session.exists()) { - return; - } - try { - await accountApi.updateSettings(payload); - } catch (e) { - console.log(`[Preferences] Error updating account settings`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } + if (!session.exists()) { + return; + } + try { + await accountApi.updateSettings(payload); + } catch (e) { + console.log(`[Preferences] Error updating account settings`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); } + } }; export default Preferences; diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index bfaccfc..e8825de 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -1,16 +1,16 @@ -import * as React from 'react'; -import {useContext, useEffect, useRef, useState} from 'react'; +import * as React from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import theme from "./theme"; import { - Checkbox, - Chip, - FormControl, - FormControlLabel, - InputLabel, - Link, - Select, - Tooltip, - useMediaQuery + Checkbox, + Chip, + FormControl, + FormControlLabel, + InputLabel, + Link, + Select, + Tooltip, + useMediaQuery, } from "@mui/material"; import TextField from "@mui/material/TextField"; import priority1 from "../img/priority-1.svg"; @@ -24,764 +24,972 @@ import DialogContent from "@mui/material/DialogContent"; import Button from "@mui/material/Button"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; -import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon'; -import {Close} from "@mui/icons-material"; +import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon"; +import { Close } from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; -import {formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils"; +import { + formatBytes, + maybeWithAuth, + topicShortUrl, + topicUrl, + validTopic, + validUrl, +} from "../app/utils"; import Box from "@mui/material/Box"; import AttachmentIcon from "./AttachmentIcon"; import DialogFooter from "./DialogFooter"; import api from "../app/Api"; import userManager from "../app/UserManager"; import EmojiPicker from "./EmojiPicker"; -import {Trans, useTranslation} from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import session from "../app/Session"; import routes from "./routes"; import accountApi from "../app/AccountApi"; -import {UnauthorizedError} from "../app/errors"; -import {AccountContext} from "./App"; +import { UnauthorizedError } from "../app/errors"; +import { AccountContext } from "./App"; const PublishDialog = (props) => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [baseUrl, setBaseUrl] = useState(""); - const [topic, setTopic] = useState(""); - const [message, setMessage] = useState(""); - const [messageFocused, setMessageFocused] = useState(true); - const [title, setTitle] = useState(""); - const [tags, setTags] = useState(""); - const [priority, setPriority] = useState(3); - const [clickUrl, setClickUrl] = useState(""); - const [attachUrl, setAttachUrl] = useState(""); - const [attachFile, setAttachFile] = useState(null); - const [filename, setFilename] = useState(""); - const [filenameEdited, setFilenameEdited] = useState(false); - const [email, setEmail] = useState(""); - const [call, setCall] = useState(""); - const [delay, setDelay] = useState(""); - const [publishAnother, setPublishAnother] = useState(false); + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [baseUrl, setBaseUrl] = useState(""); + const [topic, setTopic] = useState(""); + const [message, setMessage] = useState(""); + const [messageFocused, setMessageFocused] = useState(true); + const [title, setTitle] = useState(""); + const [tags, setTags] = useState(""); + const [priority, setPriority] = useState(3); + const [clickUrl, setClickUrl] = useState(""); + const [attachUrl, setAttachUrl] = useState(""); + const [attachFile, setAttachFile] = useState(null); + const [filename, setFilename] = useState(""); + const [filenameEdited, setFilenameEdited] = useState(false); + const [email, setEmail] = useState(""); + const [call, setCall] = useState(""); + const [delay, setDelay] = useState(""); + const [publishAnother, setPublishAnother] = useState(false); - const [showTopicUrl, setShowTopicUrl] = useState(""); - const [showClickUrl, setShowClickUrl] = useState(false); - const [showAttachUrl, setShowAttachUrl] = useState(false); - const [showEmail, setShowEmail] = useState(false); - const [showCall, setShowCall] = useState(false); - const [showDelay, setShowDelay] = useState(false); + const [showTopicUrl, setShowTopicUrl] = useState(""); + const [showClickUrl, setShowClickUrl] = useState(false); + const [showAttachUrl, setShowAttachUrl] = useState(false); + const [showEmail, setShowEmail] = useState(false); + const [showCall, setShowCall] = useState(false); + const [showDelay, setShowDelay] = useState(false); - const showAttachFile = !!attachFile && !showAttachUrl; - const attachFileInput = useRef(); - const [attachFileError, setAttachFileError] = useState(""); + const showAttachFile = !!attachFile && !showAttachUrl; + const attachFileInput = useRef(); + const [attachFileError, setAttachFileError] = useState(""); - const [activeRequest, setActiveRequest] = useState(null); - const [status, setStatus] = useState(""); - const disabled = !!activeRequest; + const [activeRequest, setActiveRequest] = useState(null); + const [status, setStatus] = useState(""); + const disabled = !!activeRequest; - const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null); + const [emojiPickerAnchorEl, setEmojiPickerAnchorEl] = useState(null); - const [dropZone, setDropZone] = useState(false); - const [sendButtonEnabled, setSendButtonEnabled] = useState(true); + const [dropZone, setDropZone] = useState(false); + const [sendButtonEnabled, setSendButtonEnabled] = useState(true); - const open = !!props.openMode; - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const open = !!props.openMode; + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - useEffect(() => { - window.addEventListener('dragenter', () => { - props.onDragEnter(); - setDropZone(true); - }); - }, []); + useEffect(() => { + window.addEventListener("dragenter", () => { + props.onDragEnter(); + setDropZone(true); + }); + }, []); - useEffect(() => { - setBaseUrl(props.baseUrl); - setTopic(props.topic); - setShowTopicUrl(!props.baseUrl || !props.topic); - setMessageFocused(!!props.topic); // Focus message only if topic is set - }, [props.baseUrl, props.topic]); + useEffect(() => { + setBaseUrl(props.baseUrl); + setTopic(props.topic); + setShowTopicUrl(!props.baseUrl || !props.topic); + setMessageFocused(!!props.topic); // Focus message only if topic is set + }, [props.baseUrl, props.topic]); - useEffect(() => { - const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError; - setSendButtonEnabled(valid); - }, [baseUrl, topic, attachFileError]); + useEffect(() => { + const valid = validUrl(baseUrl) && validTopic(topic) && !attachFileError; + setSendButtonEnabled(valid); + }, [baseUrl, topic, attachFileError]); - useEffect(() => { - setMessage(props.message); - }, [props.message]); + useEffect(() => { + setMessage(props.message); + }, [props.message]); - const updateBaseUrl = (newVal) => { - if (validUrl(newVal)) { - setBaseUrl(newVal.replace(/\/$/, '')); // strip traililng slash after https?:// + const updateBaseUrl = (newVal) => { + if (validUrl(newVal)) { + setBaseUrl(newVal.replace(/\/$/, "")); // strip traililng slash after https?:// + } else { + setBaseUrl(newVal); + } + }; + + const handleSubmit = async () => { + const url = new URL(topicUrl(baseUrl, topic)); + if (title.trim()) { + url.searchParams.append("title", title.trim()); + } + if (tags.trim()) { + url.searchParams.append("tags", tags.trim()); + } + if (priority && priority !== 3) { + url.searchParams.append("priority", priority.toString()); + } + if (clickUrl.trim()) { + url.searchParams.append("click", clickUrl.trim()); + } + if (attachUrl.trim()) { + url.searchParams.append("attach", attachUrl.trim()); + } + if (filename.trim()) { + url.searchParams.append("filename", filename.trim()); + } + if (email.trim()) { + url.searchParams.append("email", email.trim()); + } + if (call.trim()) { + url.searchParams.append("call", call.trim()); + } + if (delay.trim()) { + url.searchParams.append("delay", delay.trim()); + } + if (attachFile && message.trim()) { + url.searchParams.append( + "message", + message.replaceAll("\n", "\\n").trim() + ); + } + const body = attachFile ? attachFile : message; + try { + const user = await userManager.get(baseUrl); + const headers = maybeWithAuth({}, user); + const progressFn = (ev) => { + if (ev.loaded > 0 && ev.total > 0) { + setStatus( + t("publish_dialog_progress_uploading_detail", { + loaded: formatBytes(ev.loaded), + total: formatBytes(ev.total), + percent: Math.round((ev.loaded * 100.0) / ev.total), + }) + ); } else { - setBaseUrl(newVal); + setStatus(t("publish_dialog_progress_uploading")); } - }; + }; + const request = api.publishXHR(url, body, headers, progressFn); + setActiveRequest(request); + await request; + if (!publishAnother) { + props.onClose(); + } else { + setStatus(t("publish_dialog_message_published")); + setActiveRequest(null); + } + } catch (e) { + setStatus( + + {e} + + ); + setActiveRequest(null); + } + }; - const handleSubmit = async () => { - const url = new URL(topicUrl(baseUrl, topic)); - if (title.trim()) { - url.searchParams.append("title", title.trim()); - } - if (tags.trim()) { - url.searchParams.append("tags", tags.trim()); - } - if (priority && priority !== 3) { - url.searchParams.append("priority", priority.toString()); - } - if (clickUrl.trim()) { - url.searchParams.append("click", clickUrl.trim()); - } - if (attachUrl.trim()) { - url.searchParams.append("attach", attachUrl.trim()); - } - if (filename.trim()) { - url.searchParams.append("filename", filename.trim()); - } - if (email.trim()) { - url.searchParams.append("email", email.trim()); - } - if (call.trim()) { - url.searchParams.append("call", call.trim()); - } - if (delay.trim()) { - url.searchParams.append("delay", delay.trim()); - } - if (attachFile && message.trim()) { - url.searchParams.append("message", message.replaceAll("\n", "\\n").trim()); - } - const body = (attachFile) ? attachFile : message; - try { - const user = await userManager.get(baseUrl); - const headers = maybeWithAuth({}, user); - const progressFn = (ev) => { - if (ev.loaded > 0 && ev.total > 0) { - setStatus(t("publish_dialog_progress_uploading_detail", { - loaded: formatBytes(ev.loaded), - total: formatBytes(ev.total), - percent: Math.round(ev.loaded * 100.0 / ev.total) - })); - } else { - setStatus(t("publish_dialog_progress_uploading")); + const checkAttachmentLimits = async (file) => { + try { + const account = await accountApi.get(); + const fileSizeLimit = account.limits.attachment_file_size ?? 0; + const remainingBytes = account.stats.attachment_total_size_remaining; + const fileSizeLimitReached = + fileSizeLimit > 0 && file.size > fileSizeLimit; + const quotaReached = remainingBytes > 0 && file.size > remainingBytes; + if (fileSizeLimitReached && quotaReached) { + return setAttachFileError( + t("publish_dialog_attachment_limits_file_and_quota_reached", { + fileSizeLimit: formatBytes(fileSizeLimit), + remainingBytes: formatBytes(remainingBytes), + }) + ); + } else if (fileSizeLimitReached) { + return setAttachFileError( + t("publish_dialog_attachment_limits_file_reached", { + fileSizeLimit: formatBytes(fileSizeLimit), + }) + ); + } else if (quotaReached) { + return setAttachFileError( + t("publish_dialog_attachment_limits_quota_reached", { + remainingBytes: formatBytes(remainingBytes), + }) + ); + } + setAttachFileError(""); + } catch (e) { + console.log(`[PublishDialog] Retrieving attachment limits failed`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setAttachFileError(""); // Reset error (rely on server-side checking) + } + } + }; + + const handleAttachFileClick = () => { + attachFileInput.current.click(); + }; + + const handleAttachFileChanged = async (ev) => { + await updateAttachFile(ev.target.files[0]); + }; + + const handleAttachFileDrop = async (ev) => { + ev.preventDefault(); + setDropZone(false); + await updateAttachFile(ev.dataTransfer.files[0]); + }; + + const updateAttachFile = async (file) => { + setAttachFile(file); + setFilename(file.name); + props.onResetOpenMode(); + await checkAttachmentLimits(file); + }; + + const handleAttachFileDragLeave = () => { + setDropZone(false); + if (props.openMode === PublishDialog.OPEN_MODE_DRAG) { + props.onClose(); // Only close dialog if it was not open before dragging file in + } + }; + + const handleEmojiClick = (ev) => { + setEmojiPickerAnchorEl(ev.currentTarget); + }; + + const handleEmojiPick = (emoji) => { + setTags((tags) => (tags.trim() ? `${tags.trim()}, ${emoji}` : emoji)); + }; + + const handleEmojiClose = () => { + setEmojiPickerAnchorEl(null); + }; + + const priorities = { + 1: { label: t("publish_dialog_priority_min"), file: priority1 }, + 2: { label: t("publish_dialog_priority_low"), file: priority2 }, + 3: { label: t("publish_dialog_priority_default"), file: priority3 }, + 4: { label: t("publish_dialog_priority_high"), file: priority4 }, + 5: { label: t("publish_dialog_priority_max"), file: priority5 }, + }; + + return ( + <> + {dropZone && ( + + )} + + + {baseUrl && topic + ? t("publish_dialog_title_topic", { + topic: topicShortUrl(baseUrl, topic), + }) + : t("publish_dialog_title_no_topic")} + + + {dropZone && } + {showTopicUrl && ( + { + setBaseUrl(props.baseUrl); + setTopic(props.topic); + setShowTopicUrl(false); + }} + > + updateBaseUrl(ev.target.value)} + disabled={disabled} + type="url" + variant="standard" + sx={{ flexGrow: 1, marginRight: 1 }} + inputProps={{ + "aria-label": t("publish_dialog_base_url_label"), + }} + /> + setTopic(ev.target.value)} + disabled={disabled} + type="text" + variant="standard" + autoFocus={!messageFocused} + sx={{ flexGrow: 1 }} + inputProps={{ + "aria-label": t("publish_dialog_topic_label"), + }} + /> + + )} + setTitle(ev.target.value)} + disabled={disabled} + type="text" + fullWidth + variant="standard" + inputProps={{ + "aria-label": t("publish_dialog_title_label"), + }} + /> + setMessage(ev.target.value)} + disabled={disabled} + type="text" + variant="standard" + rows={5} + autoFocus={messageFocused} + fullWidth + multiline + inputProps={{ + "aria-label": t("publish_dialog_message_label"), + }} + /> +
+ + + + + setTags(ev.target.value)} + disabled={disabled} + type="text" + variant="standard" + sx={{ flexGrow: 1, marginRight: 1 }} + inputProps={{ + "aria-label": t("publish_dialog_tags_label"), + }} + /> + + + + +
+ {showClickUrl && ( + { + setClickUrl(""); + setShowClickUrl(false); + }} + > + setClickUrl(ev.target.value)} + disabled={disabled} + type="url" + fullWidth + variant="standard" + inputProps={{ + "aria-label": t("publish_dialog_click_label"), + }} + /> + + )} + {showEmail && ( + { + setEmail(""); + setShowEmail(false); + }} + > + setEmail(ev.target.value)} + disabled={disabled} + type="email" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_email_label"), + }} + /> + + )} + {showCall && ( + { + setCall(""); + setShowCall(false); + }} + > + + + + + + )} + {showAttachUrl && ( + { + setAttachUrl(""); + setFilename(""); + setFilenameEdited(false); + setShowAttachUrl(false); + }} + > + { + const url = ev.target.value; + setAttachUrl(url); + if (!filenameEdited) { + try { + const u = new URL(url); + const parts = u.pathname.split("/"); + if (parts.length > 0) { + setFilename(parts[parts.length - 1]); + } + } catch (e) { + // Do nothing + } + } + }} + disabled={disabled} + type="url" + variant="standard" + sx={{ flexGrow: 5, marginRight: 1 }} + inputProps={{ + "aria-label": t("publish_dialog_attach_label"), + }} + /> + { + setFilename(ev.target.value); + setFilenameEdited(true); + }} + disabled={disabled} + type="text" + variant="standard" + sx={{ flexGrow: 1 }} + inputProps={{ + "aria-label": t("publish_dialog_filename_label"), + }} + /> + + )} + + {showAttachFile && ( + setFilename(f)} + onClose={() => { + setAttachFile(null); + setAttachFileError(""); + setFilename(""); + }} + /> + )} + {showDelay && ( + { + setDelay(""); + setShowDelay(false); + }} + > + setDelay(ev.target.value)} + disabled={disabled} + type="text" + variant="standard" + fullWidth + inputProps={{ + "aria-label": t("publish_dialog_delay_label"), + }} + /> + + )} + + {t("publish_dialog_other_features")} + +
+ {!showClickUrl && ( + setShowClickUrl(true)} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {!showEmail && ( + setShowEmail(true)} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {account?.phone_numbers?.length > 0 && !showCall && ( + { + setShowCall(true); + setCall(account.phone_numbers[0]); + }} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {!showAttachUrl && !showAttachFile && ( + setShowAttachUrl(true)} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {!showAttachFile && !showAttachUrl && ( + handleAttachFileClick()} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {!showDelay && ( + setShowDelay(true)} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {!showTopicUrl && ( + setShowTopicUrl(true)} + sx={{ marginRight: 1, marginBottom: 1 }} + /> + )} + {account && !account?.phone_numbers && ( + + + + + + )} +
+ + + ), + }} + /> + +
+ + {activeRequest && ( + + )} + {!activeRequest && ( + <> + setPublishAnother(ev.target.checked)} + inputProps={{ + "aria-label": t( + "publish_dialog_checkbox_publish_another" + ), + }} + /> } - }; - const request = api.publishXHR(url, body, headers, progressFn); - setActiveRequest(request); - await request; - if (!publishAnother) { - props.onClose(); - } else { - setStatus(t("publish_dialog_message_published")); - setActiveRequest(null); - } - } catch (e) { - setStatus({e}); - setActiveRequest(null); - } - }; - - const checkAttachmentLimits = async (file) => { - try { - const account = await accountApi.get(); - const fileSizeLimit = account.limits.attachment_file_size ?? 0; - const remainingBytes = account.stats.attachment_total_size_remaining; - const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit; - const quotaReached = remainingBytes > 0 && file.size > remainingBytes; - if (fileSizeLimitReached && quotaReached) { - return setAttachFileError(t("publish_dialog_attachment_limits_file_and_quota_reached", { - fileSizeLimit: formatBytes(fileSizeLimit), - remainingBytes: formatBytes(remainingBytes) - })); - } else if (fileSizeLimitReached) { - return setAttachFileError(t("publish_dialog_attachment_limits_file_reached", { fileSizeLimit: formatBytes(fileSizeLimit) })); - } else if (quotaReached) { - return setAttachFileError(t("publish_dialog_attachment_limits_quota_reached", { remainingBytes: formatBytes(remainingBytes) })); - } - setAttachFileError(""); - } catch (e) { - console.log(`[PublishDialog] Retrieving attachment limits failed`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setAttachFileError(""); // Reset error (rely on server-side checking) - } - } - }; - - const handleAttachFileClick = () => { - attachFileInput.current.click(); - }; - - const handleAttachFileChanged = async (ev) => { - await updateAttachFile(ev.target.files[0]); - }; - - const handleAttachFileDrop = async (ev) => { - ev.preventDefault(); - setDropZone(false); - await updateAttachFile(ev.dataTransfer.files[0]); - }; - - const updateAttachFile = async (file) => { - setAttachFile(file); - setFilename(file.name); - props.onResetOpenMode(); - await checkAttachmentLimits(file); - }; - - const handleAttachFileDragLeave = () => { - setDropZone(false); - if (props.openMode === PublishDialog.OPEN_MODE_DRAG) { - props.onClose(); // Only close dialog if it was not open before dragging file in - } - }; - - const handleEmojiClick = (ev) => { - setEmojiPickerAnchorEl(ev.currentTarget); - }; - - const handleEmojiPick = (emoji) => { - setTags(tags => (tags.trim()) ? `${tags.trim()}, ${emoji}` : emoji); - }; - - const handleEmojiClose = () => { - setEmojiPickerAnchorEl(null); - }; - - const priorities = { - 1: { label: t("publish_dialog_priority_min"), file: priority1 }, - 2: { label: t("publish_dialog_priority_low"), file: priority2 }, - 3: { label: t("publish_dialog_priority_default"), file: priority3 }, - 4: { label: t("publish_dialog_priority_high"), file: priority4 }, - 5: { label: t("publish_dialog_priority_max"), file: priority5 } - }; - - return ( - <> - {dropZone && - } - - {(baseUrl && topic) ? t("publish_dialog_title_topic", { topic: topicShortUrl(baseUrl, topic) }) : t("publish_dialog_title_no_topic")} - - {dropZone && } - {showTopicUrl && - { - setBaseUrl(props.baseUrl); - setTopic(props.topic); - setShowTopicUrl(false); - }}> - updateBaseUrl(ev.target.value)} - disabled={disabled} - type="url" - variant="standard" - sx={{flexGrow: 1, marginRight: 1}} - inputProps={{ - "aria-label": t("publish_dialog_base_url_label") - }} - /> - setTopic(ev.target.value)} - disabled={disabled} - type="text" - variant="standard" - autoFocus={!messageFocused} - sx={{flexGrow: 1}} - inputProps={{ - "aria-label": t("publish_dialog_topic_label") - }} - /> - - } - setTitle(ev.target.value)} - disabled={disabled} - type="text" - fullWidth - variant="standard" - inputProps={{ - "aria-label": t("publish_dialog_title_label") - }} - /> - setMessage(ev.target.value)} - disabled={disabled} - type="text" - variant="standard" - rows={5} - autoFocus={messageFocused} - fullWidth - multiline - inputProps={{ - "aria-label": t("publish_dialog_message_label") - }} - /> -
- - - - - setTags(ev.target.value)} - disabled={disabled} - type="text" - variant="standard" - sx={{flexGrow: 1, marginRight: 1}} - inputProps={{ - "aria-label": t("publish_dialog_tags_label") - }} - /> - - - - -
- {showClickUrl && - { - setClickUrl(""); - setShowClickUrl(false); - }}> - setClickUrl(ev.target.value)} - disabled={disabled} - type="url" - fullWidth - variant="standard" - inputProps={{ - "aria-label": t("publish_dialog_click_label") - }} - /> - - } - {showEmail && - { - setEmail(""); - setShowEmail(false); - }}> - setEmail(ev.target.value)} - disabled={disabled} - type="email" - variant="standard" - fullWidth - inputProps={{ - "aria-label": t("publish_dialog_email_label") - }} - /> - - } - {showCall && - { - setCall(""); - setShowCall(false); - }}> - - - - - - } - {showAttachUrl && - { - setAttachUrl(""); - setFilename(""); - setFilenameEdited(false); - setShowAttachUrl(false); - }}> - { - const url = ev.target.value; - setAttachUrl(url); - if (!filenameEdited) { - try { - const u = new URL(url); - const parts = u.pathname.split("/"); - if (parts.length > 0) { - setFilename(parts[parts.length-1]); - } - } catch (e) { - // Do nothing - } - } - }} - disabled={disabled} - type="url" - variant="standard" - sx={{flexGrow: 5, marginRight: 1}} - inputProps={{ - "aria-label": t("publish_dialog_attach_label") - }} - /> - { - setFilename(ev.target.value); - setFilenameEdited(true); - }} - disabled={disabled} - type="text" - variant="standard" - sx={{flexGrow: 1}} - inputProps={{ - "aria-label": t("publish_dialog_filename_label") - }} - /> - - } - - {showAttachFile && setFilename(f)} - onClose={() => { - setAttachFile(null); - setAttachFileError(""); - setFilename(""); - }} - />} - {showDelay && - { - setDelay(""); - setShowDelay(false); - }}> - setDelay(ev.target.value)} - disabled={disabled} - type="text" - variant="standard" - fullWidth - inputProps={{ - "aria-label": t("publish_dialog_delay_label") - }} - /> - - } - - {t("publish_dialog_other_features")} - -
- {!showClickUrl && setShowClickUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showEmail && setShowEmail(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {account?.phone_numbers?.length > 0 && !showCall && { setShowCall(true); setCall(account.phone_numbers[0]); }} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showAttachUrl && !showAttachFile && setShowAttachUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showAttachFile && !showAttachUrl && handleAttachFileClick()} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showDelay && setShowDelay(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {!showTopicUrl && setShowTopicUrl(true)} sx={{marginRight: 1, marginBottom: 1}}/>} - {account && !account?.phone_numbers && } -
- - - }} - /> - -
- - {activeRequest && } - {!activeRequest && - <> - setPublishAnother(ev.target.checked)} - inputProps={{ - "aria-label": t("publish_dialog_checkbox_publish_another") - }} /> - } /> - - - - } - -
- - ); + /> + + + + )} +
+
+ + ); }; const Row = (props) => { - return ( -
- {props.children} -
- ); + return ( +
+ {props.children} +
+ ); }; const ClosableRow = (props) => { - const closable = (props.hasOwnProperty("closable")) ? props.closable : true; - return ( - - {props.children} - {closable && - - - - } - - ); + const closable = props.hasOwnProperty("closable") ? props.closable : true; + return ( + + {props.children} + {closable && ( + + + + )} + + ); }; const DialogIconButton = (props) => { - const sx = props.sx || {}; - return ( - - {props.children} - - ); + const sx = props.sx || {}; + return ( + + {props.children} + + ); }; const AttachmentBox = (props) => { - const { t } = useTranslation(); - const file = props.file; - return ( - <> - - {t("publish_dialog_attached_file_title")} - - - - - props.onChangeFilename(ev.target.value)} - disabled={props.disabled} - /> -
- - {formatBytes(file.size)} - {props.error && - - {" "}({props.error}) - - } - -
- - - -
- - ); + const { t } = useTranslation(); + const file = props.file; + return ( + <> + + {t("publish_dialog_attached_file_title")} + + + + + props.onChangeFilename(ev.target.value)} + disabled={props.disabled} + /> +
+ + {formatBytes(file.size)} + {props.error && ( + + {" "} + ({props.error}) + + )} + +
+ + + +
+ + ); }; const ExpandingTextField = (props) => { - const invisibleFieldRef = useRef(); - const [textWidth, setTextWidth] = useState(props.minWidth); - const determineTextWidth = () => { - const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect(); - if (!boundingRect) { - return props.minWidth; - } - return (boundingRect.width >= props.minWidth) ? Math.round(boundingRect.width) : props.minWidth; - }; - useEffect(() => { - setTextWidth(determineTextWidth() + 5); - }, [props.value]); - return ( - <> - - {props.value} - - - - ) + const invisibleFieldRef = useRef(); + const [textWidth, setTextWidth] = useState(props.minWidth); + const determineTextWidth = () => { + const boundingRect = invisibleFieldRef?.current?.getBoundingClientRect(); + if (!boundingRect) { + return props.minWidth; + } + return boundingRect.width >= props.minWidth + ? Math.round(boundingRect.width) + : props.minWidth; + }; + useEffect(() => { + setTextWidth(determineTextWidth() + 5); + }, [props.value]); + return ( + <> + + {props.value} + + + + ); }; const DropArea = (props) => { - const allowDrag = (ev) => { - // This is where we could disallow certain files to be dragged in. - // For now we allow all files. + const allowDrag = (ev) => { + // This is where we could disallow certain files to be dragged in. + // For now we allow all files. - ev.dataTransfer.dropEffect = 'copy'; - ev.preventDefault(); - }; + ev.dataTransfer.dropEffect = "copy"; + ev.preventDefault(); + }; - return ( - - ); + return ( + + ); }; const DropBox = () => { - const { t } = useTranslation(); - return ( - - - {t("publish_dialog_drop_file_here")} - - - ); -} + const { t } = useTranslation(); + return ( + + + + {t("publish_dialog_drop_file_here")} + + + + ); +}; PublishDialog.OPEN_MODE_DEFAULT = "default"; PublishDialog.OPEN_MODE_DRAG = "drag"; diff --git a/web/src/components/ReserveDialogs.js b/web/src/components/ReserveDialogs.js index e466269..f36ea6c 100644 --- a/web/src/components/ReserveDialogs.js +++ b/web/src/components/ReserveDialogs.js @@ -1,199 +1,239 @@ -import * as React from 'react'; -import {useState} from 'react'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogContentText from '@mui/material/DialogContentText'; -import DialogTitle from '@mui/material/DialogTitle'; -import {Alert, FormControl, Select, useMediaQuery} from "@mui/material"; +import * as React from "react"; +import { useState } from "react"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Dialog from "@mui/material/Dialog"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import { Alert, FormControl, Select, useMediaQuery } from "@mui/material"; import theme from "./theme"; -import {validTopic} from "../app/utils"; +import { validTopic } from "../app/utils"; import DialogFooter from "./DialogFooter"; -import {useTranslation} from "react-i18next"; +import { useTranslation } from "react-i18next"; import session from "../app/Session"; import routes from "./routes"; -import accountApi, {Permission} from "../app/AccountApi"; +import accountApi, { Permission } from "../app/AccountApi"; import ReserveTopicSelect from "./ReserveTopicSelect"; import MenuItem from "@mui/material/MenuItem"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; -import {Check, DeleteForever} from "@mui/icons-material"; -import {TopicReservedError, UnauthorizedError} from "../app/errors"; +import { Check, DeleteForever } from "@mui/icons-material"; +import { TopicReservedError, UnauthorizedError } from "../app/errors"; export const ReserveAddDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [topic, setTopic] = useState(props.topic || ""); - const [everyone, setEveryone] = useState(Permission.DENY_ALL); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - const allowTopicEdit = !props.topic; - const alreadyReserved = props.reservations.filter(r => r.topic === topic).length > 0; - const submitButtonEnabled = validTopic(topic) && !alreadyReserved; + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [topic, setTopic] = useState(props.topic || ""); + const [everyone, setEveryone] = useState(Permission.DENY_ALL); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); + const allowTopicEdit = !props.topic; + const alreadyReserved = + props.reservations.filter((r) => r.topic === topic).length > 0; + const submitButtonEnabled = validTopic(topic) && !alreadyReserved; - const handleSubmit = async () => { - try { - await accountApi.upsertReservation(topic, everyone); - console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`); - } catch (e) { - console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else if (e instanceof TopicReservedError) { - setError(t("subscribe_dialog_error_topic_already_reserved")); - return; - } else { - setError(e.message); - return; - } - } - props.onClose(); - }; + const handleSubmit = async () => { + try { + await accountApi.upsertReservation(topic, everyone); + console.debug( + `[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}` + ); + } catch (e) { + console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else if (e instanceof TopicReservedError) { + setError(t("subscribe_dialog_error_topic_already_reserved")); + return; + } else { + setError(e.message); + return; + } + } + props.onClose(); + }; - return ( - - {t("prefs_reservations_dialog_title_add")} - - - {t("prefs_reservations_dialog_description")} - - {allowTopicEdit && setTopic(ev.target.value)} - type="url" - fullWidth - variant="standard" - />} - - - - - - - - ); + return ( + + {t("prefs_reservations_dialog_title_add")} + + + {t("prefs_reservations_dialog_description")} + + {allowTopicEdit && ( + setTopic(ev.target.value)} + type="url" + fullWidth + variant="standard" + /> + )} + + + + + + + + ); }; export const ReserveEditDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [everyone, setEveryone] = useState( + props.reservation?.everyone || Permission.DENY_ALL + ); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const handleSubmit = async () => { - try { - await accountApi.upsertReservation(props.reservation.topic, everyone); - console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`); - } catch (e) { - console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - return; - } - } - props.onClose(); - }; + const handleSubmit = async () => { + try { + await accountApi.upsertReservation(props.reservation.topic, everyone); + console.debug( + `[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}` + ); + } catch (e) { + console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + return; + } + } + props.onClose(); + }; - return ( - - {t("prefs_reservations_dialog_title_edit")} - - - {t("prefs_reservations_dialog_description")} - - - - - - - - - ); + return ( + + {t("prefs_reservations_dialog_title_edit")} + + + {t("prefs_reservations_dialog_description")} + + + + + + + + + ); }; export const ReserveDeleteDialog = (props) => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [deleteMessages, setDeleteMessages] = useState(false); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [deleteMessages, setDeleteMessages] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const handleSubmit = async () => { - try { - await accountApi.deleteReservation(props.topic, deleteMessages); - console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`); - } catch (e) { - console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - return; - } - } - props.onClose(); - }; + const handleSubmit = async () => { + try { + await accountApi.deleteReservation(props.topic, deleteMessages); + console.debug( + `[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}` + ); + } catch (e) { + console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + return; + } + } + props.onClose(); + }; - return ( - - {t("prefs_reservations_dialog_title_delete")} - - - {t("reservation_delete_dialog_description")} - - - - - {!deleteMessages && - - {t("reservation_delete_dialog_action_keep_description")} - - } - {deleteMessages && - - {t("reservation_delete_dialog_action_delete_description")} - - } - - - - - - - ); + return ( + + {t("prefs_reservations_dialog_title_delete")} + + + {t("reservation_delete_dialog_description")} + + + + + {!deleteMessages && ( + + {t("reservation_delete_dialog_action_keep_description")} + + )} + {deleteMessages && ( + + {t("reservation_delete_dialog_action_delete_description")} + + )} + + + + + + + ); }; - diff --git a/web/src/components/ReserveIcons.js b/web/src/components/ReserveIcons.js index 0d7b05b..3b22df3 100644 --- a/web/src/components/ReserveIcons.js +++ b/web/src/components/ReserveIcons.js @@ -1,46 +1,55 @@ -import * as React from 'react'; -import {Lock, Public} from "@mui/icons-material"; +import * as React from "react"; +import { Lock, Public } from "@mui/icons-material"; import Box from "@mui/material/Box"; export const PermissionReadWrite = React.forwardRef((props, ref) => { - return ; + return ; }); export const PermissionDenyAll = React.forwardRef((props, ref) => { - return ; + return ; }); export const PermissionRead = React.forwardRef((props, ref) => { - return ; + return ; }); export const PermissionWrite = React.forwardRef((props, ref) => { - return ; + return ; }); const PermissionInternal = React.forwardRef((props, ref) => { - const size = props.size ?? "medium"; - const Icon = props.icon; - return ( - - - {props.text && - - {props.text} - - } + const size = props.size ?? "medium"; + const Icon = props.icon; + return ( + + + {props.text && ( + + {props.text} - ); + )} + + ); }); diff --git a/web/src/components/ReserveTopicSelect.js b/web/src/components/ReserveTopicSelect.js index e5daf69..76113ba 100644 --- a/web/src/components/ReserveTopicSelect.js +++ b/web/src/components/ReserveTopicSelect.js @@ -1,49 +1,70 @@ -import * as React from 'react'; -import {FormControl, Select} from "@mui/material"; -import {useTranslation} from "react-i18next"; +import * as React from "react"; +import { FormControl, Select } from "@mui/material"; +import { useTranslation } from "react-i18next"; import MenuItem from "@mui/material/MenuItem"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; -import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons"; -import {Permission} from "../app/AccountApi"; +import { + PermissionDenyAll, + PermissionRead, + PermissionReadWrite, + PermissionWrite, +} from "./ReserveIcons"; +import { Permission } from "../app/AccountApi"; const ReserveTopicSelect = (props) => { - const { t } = useTranslation(); - const sx = props.sx || {}; - return ( - - - - ); + const { t } = useTranslation(); + const sx = props.sx || {}; + return ( + + + + ); }; export default ReserveTopicSelect; diff --git a/web/src/components/Signup.js b/web/src/components/Signup.js index 856ce8f..39409a5 100644 --- a/web/src/components/Signup.js +++ b/web/src/components/Signup.js @@ -1,158 +1,167 @@ -import * as React from 'react'; -import {useState} from 'react'; +import * as React from "react"; +import { useState } from "react"; import TextField from "@mui/material/TextField"; import Button from "@mui/material/Button"; import Box from "@mui/material/Box"; import routes from "./routes"; import session from "../app/Session"; import Typography from "@mui/material/Typography"; -import {NavLink} from "react-router-dom"; +import { NavLink } from "react-router-dom"; import AvatarBox from "./AvatarBox"; -import {useTranslation} from "react-i18next"; +import { useTranslation } from "react-i18next"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import accountApi from "../app/AccountApi"; -import {InputAdornment} from "@mui/material"; +import { InputAdornment } from "@mui/material"; import IconButton from "@mui/material/IconButton"; -import {Visibility, VisibilityOff} from "@mui/icons-material"; -import {AccountCreateLimitReachedError, UserExistsError} from "../app/errors"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { AccountCreateLimitReachedError, UserExistsError } from "../app/errors"; const Signup = () => { - const { t } = useTranslation(); - const [error, setError] = useState(""); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [confirm, setConfirm] = useState(""); - const [showPassword, setShowPassword] = useState(false); - const [showConfirm, setShowConfirm] = useState(false); + const { t } = useTranslation(); + const [error, setError] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); - const handleSubmit = async (event) => { - event.preventDefault(); - const user = { username, password }; - try { - await accountApi.create(user.username, user.password); - const token = await accountApi.login(user); - console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); - session.store(user.username, token); - window.location.href = routes.app; - } catch (e) { - console.log(`[Signup] Signup for user ${user.username} failed`, e); - if (e instanceof UserExistsError) { - setError(t("signup_error_username_taken", { username: e.username })); - } else if ((e instanceof AccountCreateLimitReachedError)) { - setError(t("signup_error_creation_limit_reached")); - } else { - setError(e.message); - } - } - }; - - if (!config.enable_signup) { - return ( - - {t("signup_disabled")} - - ); + const handleSubmit = async (event) => { + event.preventDefault(); + const user = { username, password }; + try { + await accountApi.create(user.username, user.password); + const token = await accountApi.login(user); + console.log( + `[Signup] User signup for user ${user.username} successful, token is ${token}` + ); + session.store(user.username, token); + window.location.href = routes.app; + } catch (e) { + console.log(`[Signup] Signup for user ${user.username} failed`, e); + if (e instanceof UserExistsError) { + setError(t("signup_error_username_taken", { username: e.username })); + } else if (e instanceof AccountCreateLimitReachedError) { + setError(t("signup_error_creation_limit_reached")); + } else { + setError(e.message); + } } + }; + if (!config.enable_signup) { return ( - - - {t("signup_title")} - - - setUsername(ev.target.value.trim())} - autoFocus - /> - setPassword(ev.target.value.trim())} - InputProps={{ - endAdornment: ( - - setShowPassword(!showPassword)} - onMouseDown={(ev) => ev.preventDefault()} - edge="end" - > - {showPassword ? : } - - - ) - }} - /> - setConfirm(ev.target.value.trim())} - InputProps={{ - endAdornment: ( - - setShowConfirm(!showConfirm)} - onMouseDown={(ev) => ev.preventDefault()} - edge="end" - > - {showConfirm ? : } - - - ) - }} - /> - - {error && - - - {error} - - } - - {config.enable_login && - - - {t("signup_already_have_account")} - - - } - + + + {t("signup_disabled")} + + ); -} + } + + return ( + + {t("signup_title")} + + setUsername(ev.target.value.trim())} + autoFocus + /> + setPassword(ev.target.value.trim())} + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} + onMouseDown={(ev) => ev.preventDefault()} + edge="end" + > + {showPassword ? : } + + + ), + }} + /> + setConfirm(ev.target.value.trim())} + InputProps={{ + endAdornment: ( + + setShowConfirm(!showConfirm)} + onMouseDown={(ev) => ev.preventDefault()} + edge="end" + > + {showConfirm ? : } + + + ), + }} + /> + + {error && ( + + + {error} + + )} + + {config.enable_login && ( + + + {t("signup_already_have_account")} + + + )} + + ); +}; export default Signup; diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 95f1c47..940eafe 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -1,313 +1,388 @@ -import * as React from 'react'; -import {useContext, useState} from 'react'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogContentText from '@mui/material/DialogContentText'; -import DialogTitle from '@mui/material/DialogTitle'; -import {Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery} from "@mui/material"; +import * as React from "react"; +import { useContext, useState } from "react"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Dialog from "@mui/material/Dialog"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import { + Autocomplete, + Checkbox, + FormControlLabel, + FormGroup, + useMediaQuery, +} from "@mui/material"; import theme from "./theme"; import api from "../app/Api"; -import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils"; +import { + randomAlphanumericString, + topicUrl, + validTopic, + validUrl, +} from "../app/utils"; import userManager from "../app/UserManager"; import subscriptionManager from "../app/SubscriptionManager"; import poller from "../app/Poller"; import DialogFooter from "./DialogFooter"; -import {useTranslation} from "react-i18next"; +import { useTranslation } from "react-i18next"; import session from "../app/Session"; import routes from "./routes"; -import accountApi, {Permission, Role} from "../app/AccountApi"; +import accountApi, { Permission, Role } from "../app/AccountApi"; import ReserveTopicSelect from "./ReserveTopicSelect"; -import {AccountContext} from "./App"; -import {TopicReservedError, UnauthorizedError} from "../app/errors"; -import {ReserveLimitChip} from "./SubscriptionPopup"; +import { AccountContext } from "./App"; +import { TopicReservedError, UnauthorizedError } from "../app/errors"; +import { ReserveLimitChip } from "./SubscriptionPopup"; const publicBaseUrl = "https://ntfy.sh"; const SubscribeDialog = (props) => { - const [baseUrl, setBaseUrl] = useState(""); - const [topic, setTopic] = useState(""); - const [showLoginPage, setShowLoginPage] = useState(false); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const [baseUrl, setBaseUrl] = useState(""); + const [topic, setTopic] = useState(""); + const [showLoginPage, setShowLoginPage] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const handleSuccess = async () => { - console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); - const actualBaseUrl = (baseUrl) ? baseUrl : config.base_url; - const subscription = await subscribeTopic(actualBaseUrl, topic); - poller.pollInBackground(subscription); // Dangle! - props.onSuccess(subscription); - } + const handleSuccess = async () => { + console.log(`[SubscribeDialog] Subscribing to topic ${topic}`); + const actualBaseUrl = baseUrl ? baseUrl : config.base_url; + const subscription = await subscribeTopic(actualBaseUrl, topic); + poller.pollInBackground(subscription); // Dangle! + props.onSuccess(subscription); + }; - return ( - - {!showLoginPage && setShowLoginPage(true)} - onSuccess={handleSuccess} - />} - {showLoginPage && setShowLoginPage(false)} - onSuccess={handleSuccess} - />} - - ); + return ( + + {!showLoginPage && ( + setShowLoginPage(true)} + onSuccess={handleSuccess} + /> + )} + {showLoginPage && ( + setShowLoginPage(false)} + onSuccess={handleSuccess} + /> + )} + + ); }; const SubscribePage = (props) => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const [error, setError] = useState(""); - const [reserveTopicVisible, setReserveTopicVisible] = useState(false); - const [anotherServerVisible, setAnotherServerVisible] = useState(false); - const [everyone, setEveryone] = useState(Permission.DENY_ALL); - const baseUrl = (anotherServerVisible) ? props.baseUrl : config.base_url; - const topic = props.topic; - const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic)); - const existingBaseUrls = Array - .from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)])) - .filter(s => s !== config.base_url); - const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account); - const reserveTopicEnabled = session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0)); + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const [error, setError] = useState(""); + const [reserveTopicVisible, setReserveTopicVisible] = useState(false); + const [anotherServerVisible, setAnotherServerVisible] = useState(false); + const [everyone, setEveryone] = useState(Permission.DENY_ALL); + const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url; + const topic = props.topic; + const existingTopicUrls = props.subscriptions.map((s) => + topicUrl(s.baseUrl, s.topic) + ); + const existingBaseUrls = Array.from( + new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)]) + ).filter((s) => s !== config.base_url); + const showReserveTopicCheckbox = + config.enable_reservations && + !anotherServerVisible && + (config.enable_payments || account); + const reserveTopicEnabled = + session.exists() && + (account?.role === Role.ADMIN || + (account?.role === Role.USER && + (account?.stats.reservations_remaining || 0) > 0)); - const handleSubscribe = async () => { - const user = await userManager.get(baseUrl); // May be undefined - const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous"); + const handleSubscribe = async () => { + const user = await userManager.get(baseUrl); // May be undefined + const username = user + ? user.username + : t("subscribe_dialog_error_user_anonymous"); - // Check read access to topic - const success = await api.topicAuth(baseUrl, topic, user); - if (!success) { - console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); - if (user) { - setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); - return; - } else { - props.onNeedsLogin(); - return; - } + // Check read access to topic + const success = await api.topicAuth(baseUrl, topic, user); + if (!success) { + console.log( + `[SubscribeDialog] Login to ${topicUrl( + baseUrl, + topic + )} failed for user ${username}` + ); + if (user) { + setError( + t("subscribe_dialog_error_user_not_authorized", { + username: username, + }) + ); + return; + } else { + props.onNeedsLogin(); + return; + } + } + + // Reserve topic (if requested) + if ( + session.exists() && + baseUrl === config.base_url && + reserveTopicVisible + ) { + console.log( + `[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}` + ); + try { + await accountApi.upsertReservation(topic, everyone); + } catch (e) { + console.log(`[SubscribeDialog] Error reserving topic`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else if (e instanceof TopicReservedError) { + setError(t("subscribe_dialog_error_topic_already_reserved")); + return; } + } + } - // Reserve topic (if requested) - if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) { - console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`); - try { - await accountApi.upsertReservation(topic, everyone); - } catch (e) { - console.log(`[SubscribeDialog] Error reserving topic`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else if (e instanceof TopicReservedError) { - setError(t("subscribe_dialog_error_topic_already_reserved")); - return; - } - } - } - - console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); - props.onSuccess(); - }; - - const handleUseAnotherChanged = (e) => { - props.setBaseUrl(""); - setAnotherServerVisible(e.target.checked); - }; - - const subscribeButtonEnabled = (() => { - if (anotherServerVisible) { - const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic)); - return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; - } else { - const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic)); - return validTopic(topic) && !isExistingTopicUrl; - } - })(); - - const updateBaseUrl = (ev, newVal) => { - if (validUrl(newVal)) { - props.setBaseUrl(newVal.replace(/\/$/, '')); // strip trailing slash after https?:// - } else { - props.setBaseUrl(newVal); - } - }; - - return ( - <> - {t("subscribe_dialog_subscribe_title")} - - - {t("subscribe_dialog_subscribe_description")} - -
- props.setTopic(ev.target.value)} - type="text" - fullWidth - variant="standard" - inputProps={{ - maxLength: 64, - "aria-label": t("subscribe_dialog_subscribe_topic_placeholder") - }} - /> - -
- {showReserveTopicCheckbox && - - setReserveTopicVisible(ev.target.checked)} - inputProps={{ - "aria-label": t("reserve_dialog_checkbox_label") - }} - /> - } - label={ - <> - {t("reserve_dialog_checkbox_label")} - - - } - /> - {reserveTopicVisible && - - } - - } - {!reserveTopicVisible && - - - } - label={t("subscribe_dialog_subscribe_use_another_label")}/> - {anotherServerVisible && - - } - />} - - } -
- - - - - + console.log( + `[SubscribeDialog] Successful login to ${topicUrl( + baseUrl, + topic + )} for user ${username}` ); + props.onSuccess(); + }; + + const handleUseAnotherChanged = (e) => { + props.setBaseUrl(""); + setAnotherServerVisible(e.target.checked); + }; + + const subscribeButtonEnabled = (() => { + if (anotherServerVisible) { + const isExistingTopicUrl = existingTopicUrls.includes( + topicUrl(baseUrl, topic) + ); + return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; + } else { + const isExistingTopicUrl = existingTopicUrls.includes( + topicUrl(config.base_url, topic) + ); + return validTopic(topic) && !isExistingTopicUrl; + } + })(); + + const updateBaseUrl = (ev, newVal) => { + if (validUrl(newVal)) { + props.setBaseUrl(newVal.replace(/\/$/, "")); // strip trailing slash after https?:// + } else { + props.setBaseUrl(newVal); + } + }; + + return ( + <> + {t("subscribe_dialog_subscribe_title")} + + + {t("subscribe_dialog_subscribe_description")} + +
+ props.setTopic(ev.target.value)} + type="text" + fullWidth + variant="standard" + inputProps={{ + maxLength: 64, + "aria-label": t("subscribe_dialog_subscribe_topic_placeholder"), + }} + /> + +
+ {showReserveTopicCheckbox && ( + + setReserveTopicVisible(ev.target.checked)} + inputProps={{ + "aria-label": t("reserve_dialog_checkbox_label"), + }} + /> + } + label={ + <> + {t("reserve_dialog_checkbox_label")} + + + } + /> + {reserveTopicVisible && ( + + )} + + )} + {!reserveTopicVisible && ( + + + } + label={t("subscribe_dialog_subscribe_use_another_label")} + /> + {anotherServerVisible && ( + ( + + )} + /> + )} + + )} +
+ + + + + + ); }; const LoginPage = (props) => { - const { t } = useTranslation(); - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [error, setError] = useState(""); - const baseUrl = (props.baseUrl) ? props.baseUrl : config.base_url; - const topic = props.topic; + const { t } = useTranslation(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const baseUrl = props.baseUrl ? props.baseUrl : config.base_url; + const topic = props.topic; - const handleLogin = async () => { - const user = {baseUrl, username, password}; - const success = await api.topicAuth(baseUrl, topic, user); - if (!success) { - console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); - setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); - return; - } - console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); - await userManager.save(user); - props.onSuccess(); - }; - - return ( - <> - {t("subscribe_dialog_login_title")} - - - {t("subscribe_dialog_login_description")} - - setUsername(ev.target.value)} - type="text" - fullWidth - variant="standard" - inputProps={{ - "aria-label": t("subscribe_dialog_login_username_label") - }} - /> - setPassword(ev.target.value)} - fullWidth - variant="standard" - inputProps={{ - "aria-label": t("subscribe_dialog_login_password_label") - }} - /> - - - - - - + const handleLogin = async () => { + const user = { baseUrl, username, password }; + const success = await api.topicAuth(baseUrl, topic, user); + if (!success) { + console.log( + `[SubscribeDialog] Login to ${topicUrl( + baseUrl, + topic + )} failed for user ${username}` + ); + setError( + t("subscribe_dialog_error_user_not_authorized", { username: username }) + ); + return; + } + console.log( + `[SubscribeDialog] Successful login to ${topicUrl( + baseUrl, + topic + )} for user ${username}` ); + await userManager.save(user); + props.onSuccess(); + }; + + return ( + <> + {t("subscribe_dialog_login_title")} + + + {t("subscribe_dialog_login_description")} + + setUsername(ev.target.value)} + type="text" + fullWidth + variant="standard" + inputProps={{ + "aria-label": t("subscribe_dialog_login_username_label"), + }} + /> + setPassword(ev.target.value)} + fullWidth + variant="standard" + inputProps={{ + "aria-label": t("subscribe_dialog_login_password_label"), + }} + /> + + + + + + + ); }; export const subscribeTopic = async (baseUrl, topic) => { - const subscription = await subscriptionManager.add(baseUrl, topic); - if (session.exists()) { - try { - await accountApi.addSubscription(baseUrl, topic); - } catch (e) { - console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - } + const subscription = await subscriptionManager.add(baseUrl, topic); + if (session.exists()) { + try { + await accountApi.addSubscription(baseUrl, topic); + } catch (e) { + console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } } - return subscription; + } + return subscription; }; export default SubscribeDialog; diff --git a/web/src/components/SubscriptionPopup.js b/web/src/components/SubscriptionPopup.js index 024b6f2..eb575dc 100644 --- a/web/src/components/SubscriptionPopup.js +++ b/web/src/components/SubscriptionPopup.js @@ -1,292 +1,393 @@ -import * as React from 'react'; -import {useContext, useState} from 'react'; -import Button from '@mui/material/Button'; -import TextField from '@mui/material/TextField'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogContentText from '@mui/material/DialogContentText'; -import DialogTitle from '@mui/material/DialogTitle'; -import {Chip, InputAdornment, Portal, Snackbar, useMediaQuery} from "@mui/material"; +import * as React from "react"; +import { useContext, useState } from "react"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Dialog from "@mui/material/Dialog"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import { + Chip, + InputAdornment, + Portal, + Snackbar, + useMediaQuery, +} from "@mui/material"; import theme from "./theme"; import subscriptionManager from "../app/SubscriptionManager"; import DialogFooter from "./DialogFooter"; -import {useTranslation} from "react-i18next"; -import accountApi, {Role} from "../app/AccountApi"; +import { useTranslation } from "react-i18next"; +import accountApi, { Role } from "../app/AccountApi"; import session from "../app/Session"; import routes from "./routes"; import MenuItem from "@mui/material/MenuItem"; import PopupMenu from "./PopupMenu"; -import {formatShortDateTime, shuffle} from "../app/utils"; +import { formatShortDateTime, shuffle } from "../app/utils"; import api from "../app/Api"; -import {useNavigate} from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import IconButton from "@mui/material/IconButton"; -import {Clear} from "@mui/icons-material"; -import {AccountContext} from "./App"; -import {ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog} from "./ReserveDialogs"; -import {UnauthorizedError} from "../app/errors"; +import { Clear } from "@mui/icons-material"; +import { AccountContext } from "./App"; +import { + ReserveAddDialog, + ReserveDeleteDialog, + ReserveEditDialog, +} from "./ReserveDialogs"; +import { UnauthorizedError } from "../app/errors"; export const SubscriptionPopup = (props) => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); - const navigate = useNavigate(); - const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false); - const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false); - const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false); - const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false); - const [showPublishError, setShowPublishError] = useState(false); - const subscription = props.subscription; - const placement = props.placement ?? "left"; - const reservations = account?.reservations || []; + const { t } = useTranslation(); + const { account } = useContext(AccountContext); + const navigate = useNavigate(); + const [displayNameDialogOpen, setDisplayNameDialogOpen] = useState(false); + const [reserveAddDialogOpen, setReserveAddDialogOpen] = useState(false); + const [reserveEditDialogOpen, setReserveEditDialogOpen] = useState(false); + const [reserveDeleteDialogOpen, setReserveDeleteDialogOpen] = useState(false); + const [showPublishError, setShowPublishError] = useState(false); + const subscription = props.subscription; + const placement = props.placement ?? "left"; + const reservations = account?.reservations || []; - const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0; - const showReservationAddDisabled = !showReservationAdd && config.enable_reservations && !subscription?.reservation && (config.enable_payments || account?.stats.reservations_remaining === 0); - const showReservationEdit = config.enable_reservations && !!subscription?.reservation; - const showReservationDelete = config.enable_reservations && !!subscription?.reservation; + const showReservationAdd = + config.enable_reservations && + !subscription?.reservation && + account?.stats.reservations_remaining > 0; + const showReservationAddDisabled = + !showReservationAdd && + config.enable_reservations && + !subscription?.reservation && + (config.enable_payments || account?.stats.reservations_remaining === 0); + const showReservationEdit = + config.enable_reservations && !!subscription?.reservation; + const showReservationDelete = + config.enable_reservations && !!subscription?.reservation; - const handleChangeDisplayName = async () => { - setDisplayNameDialogOpen(true); + const handleChangeDisplayName = async () => { + setDisplayNameDialogOpen(true); + }; + + const handleReserveAdd = async () => { + setReserveAddDialogOpen(true); + }; + + const handleReserveEdit = async () => { + setReserveEditDialogOpen(true); + }; + + const handleReserveDelete = async () => { + setReserveDeleteDialogOpen(true); + }; + + const handleSendTestMessage = async () => { + const baseUrl = props.subscription.baseUrl; + const topic = props.subscription.topic; + const tags = shuffle([ + "grinning", + "octopus", + "upside_down_face", + "palm_tree", + "maple_leaf", + "apple", + "skull", + "warning", + "jack_o_lantern", + "de-server-1", + "backups", + "cron-script", + "script-error", + "phils-automation", + "mouse", + "go-rocks", + "hi-ben", + ]).slice(0, Math.round(Math.random() * 4)); + const priority = shuffle([1, 2, 3, 4, 5])[0]; + const title = shuffle([ + "", + "", + "", // Higher chance of no title + "Oh my, another test message?", + "Titles are optional, did you know that?", + "ntfy is open source, and will always be free. Cool, right?", + "I don't really like apples", + "My favorite TV show is The Wire. You should watch it!", + "You can attach files and URLs to messages too", + "You can delay messages up to 3 days", + ])[0]; + const nowSeconds = Math.round(Date.now() / 1000); + const message = shuffle([ + `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime( + nowSeconds + )} right now. Is that early or late?`, + `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, + `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, + `Alright then, it's ${formatShortDateTime( + nowSeconds + )} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, + `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`, + `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`, + `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`, + ])[0]; + try { + await api.publish(baseUrl, topic, message, { + title: title, + priority: priority, + tags: tags, + }); + } catch (e) { + console.log(`[SubscriptionPopup] Error publishing message`, e); + setShowPublishError(true); } + }; - const handleReserveAdd = async () => { - setReserveAddDialogOpen(true); - } - - const handleReserveEdit = async () => { - setReserveEditDialogOpen(true); - } - - const handleReserveDelete = async () => { - setReserveDeleteDialogOpen(true); - } - - const handleSendTestMessage = async () => { - const baseUrl = props.subscription.baseUrl; - const topic = props.subscription.topic; - const tags = shuffle([ - "grinning", "octopus", "upside_down_face", "palm_tree", "maple_leaf", "apple", "skull", "warning", "jack_o_lantern", - "de-server-1", "backups", "cron-script", "script-error", "phils-automation", "mouse", "go-rocks", "hi-ben"]) - .slice(0, Math.round(Math.random() * 4)); - const priority = shuffle([1, 2, 3, 4, 5])[0]; - const title = shuffle([ - "", - "", - "", // Higher chance of no title - "Oh my, another test message?", - "Titles are optional, did you know that?", - "ntfy is open source, and will always be free. Cool, right?", - "I don't really like apples", - "My favorite TV show is The Wire. You should watch it!", - "You can attach files and URLs to messages too", - "You can delay messages up to 3 days" - ])[0]; - const nowSeconds = Math.round(Date.now()/1000); - const message = shuffle([ - `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`, - `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, - `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, - `Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, - `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`, - `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`, - `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?` - ])[0]; - try { - await api.publish(baseUrl, topic, message, { - title: title, - priority: priority, - tags: tags - }); - } catch (e) { - console.log(`[SubscriptionPopup] Error publishing message`, e); - setShowPublishError(true); - } - } - - const handleClearAll = async () => { - console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`); - await subscriptionManager.deleteNotifications(props.subscription.id); - }; - - const handleUnsubscribe = async () => { - console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription); - await subscriptionManager.remove(props.subscription.id); - if (session.exists() && !subscription.internal) { - try { - await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic); - } catch (e) { - console.log(`[SubscriptionPopup] Error unsubscribing`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - } - } - const newSelected = await subscriptionManager.first(); // May be undefined - if (newSelected && !newSelected.internal) { - navigate(routes.forSubscription(newSelected)); - } else { - navigate(routes.app); - } - }; - - return ( - <> - - {t("action_bar_change_display_name")} - {showReservationAdd && {t("action_bar_reservation_add")}} - {showReservationAddDisabled && - - {t("action_bar_reservation_add")} - - - } - {showReservationEdit && {t("action_bar_reservation_edit")}} - {showReservationDelete && {t("action_bar_reservation_delete")}} - {t("action_bar_send_test_notification")} - {t("action_bar_clear_notifications")} - {t("action_bar_unsubscribe")} - - - setShowPublishError(false)} - message={t("message_bar_error_publishing")} - /> - setDisplayNameDialogOpen(false)} - /> - {showReservationAdd && - setReserveAddDialogOpen(false)} - /> - } - {showReservationEdit && - setReserveEditDialogOpen(false)} - /> - } - {showReservationDelete && - setReserveDeleteDialogOpen(false)} - /> - } - - + const handleClearAll = async () => { + console.log( + `[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}` ); + await subscriptionManager.deleteNotifications(props.subscription.id); + }; + + const handleUnsubscribe = async () => { + console.log( + `[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, + props.subscription + ); + await subscriptionManager.remove(props.subscription.id); + if (session.exists() && !subscription.internal) { + try { + await accountApi.deleteSubscription( + props.subscription.baseUrl, + props.subscription.topic + ); + } catch (e) { + console.log(`[SubscriptionPopup] Error unsubscribing`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } + } + } + const newSelected = await subscriptionManager.first(); // May be undefined + if (newSelected && !newSelected.internal) { + navigate(routes.forSubscription(newSelected)); + } else { + navigate(routes.app); + } + }; + + return ( + <> + + + {t("action_bar_change_display_name")} + + {showReservationAdd && ( + + {t("action_bar_reservation_add")} + + )} + {showReservationAddDisabled && ( + + + {t("action_bar_reservation_add")} + + + + )} + {showReservationEdit && ( + + {t("action_bar_reservation_edit")} + + )} + {showReservationDelete && ( + + {t("action_bar_reservation_delete")} + + )} + + {t("action_bar_send_test_notification")} + + + {t("action_bar_clear_notifications")} + + + {t("action_bar_unsubscribe")} + + + + setShowPublishError(false)} + message={t("message_bar_error_publishing")} + /> + setDisplayNameDialogOpen(false)} + /> + {showReservationAdd && ( + setReserveAddDialogOpen(false)} + /> + )} + {showReservationEdit && ( + setReserveEditDialogOpen(false)} + /> + )} + {showReservationDelete && ( + setReserveDeleteDialogOpen(false)} + /> + )} + + + ); }; const DisplayNameDialog = (props) => { - const { t } = useTranslation(); - const subscription = props.subscription; - const [error, setError] = useState(""); - const [displayName, setDisplayName] = useState(subscription.displayName ?? ""); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const { t } = useTranslation(); + const subscription = props.subscription; + const [error, setError] = useState(""); + const [displayName, setDisplayName] = useState( + subscription.displayName ?? "" + ); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - const handleSave = async () => { - await subscriptionManager.setDisplayName(subscription.id, displayName); - if (session.exists() && !subscription.internal) { - try { - console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`); - await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName }); - } catch (e) { - console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - return; - } - } + const handleSave = async () => { + await subscriptionManager.setDisplayName(subscription.id, displayName); + if (session.exists() && !subscription.internal) { + try { + console.log( + `[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}` + ); + await accountApi.updateSubscription( + subscription.baseUrl, + subscription.topic, + { display_name: displayName } + ); + } catch (e) { + console.log( + `[SubscriptionSettingsDialog] Error updating subscription`, + e + ); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + return; } - props.onClose(); + } } + props.onClose(); + }; - return ( - - {t("display_name_dialog_title")} - - - {t("display_name_dialog_description")} - - setDisplayName(ev.target.value)} - type="text" - fullWidth - variant="standard" - inputProps={{ - maxLength: 64, - "aria-label": t("display_name_dialog_placeholder") - }} - InputProps={{ - endAdornment: ( - - setDisplayName("")} edge="end"> - - - - ) - }} - /> - - - - - - - ); + return ( + + {t("display_name_dialog_title")} + + + {t("display_name_dialog_description")} + + setDisplayName(ev.target.value)} + type="text" + fullWidth + variant="standard" + inputProps={{ + maxLength: 64, + "aria-label": t("display_name_dialog_placeholder"), + }} + InputProps={{ + endAdornment: ( + + setDisplayName("")} edge="end"> + + + + ), + }} + /> + + + + + + + ); }; export const ReserveLimitChip = () => { - const { account } = useContext(AccountContext); - if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) { - return <>; - } else if (config.enable_payments) { - return (account?.limits.reservations > 0) ? : ; - } else if (account) { - return ; - } + const { account } = useContext(AccountContext); + if ( + account?.role === Role.ADMIN || + account?.stats.reservations_remaining > 0 + ) { return <>; + } else if (config.enable_payments) { + return account?.limits.reservations > 0 ? ( + + ) : ( + + ); + } else if (account) { + return ; + } + return <>; }; const LimitReachedChip = () => { - const { t } = useTranslation(); - return ( - - ); + const { t } = useTranslation(); + return ( + + ); }; export const ProChip = () => { - const { t } = useTranslation(); - return ( - - ); + const { t } = useTranslation(); + return ( + + ); }; - - diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js index 0b91b1b..94b878c 100644 --- a/web/src/components/UpgradeDialog.js +++ b/web/src/components/UpgradeDialog.js @@ -1,367 +1,500 @@ -import * as React from 'react'; -import {useContext, useEffect, useState} from 'react'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import {Alert, CardActionArea, CardContent, Chip, Link, ListItem, Switch, useMediaQuery} from "@mui/material"; +import * as React from "react"; +import { useContext, useEffect, useState } from "react"; +import Dialog from "@mui/material/Dialog"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import { + Alert, + CardActionArea, + CardContent, + Chip, + Link, + ListItem, + Switch, + useMediaQuery, +} from "@mui/material"; import theme from "./theme"; import Button from "@mui/material/Button"; -import accountApi, {SubscriptionInterval} from "../app/AccountApi"; +import accountApi, { SubscriptionInterval } from "../app/AccountApi"; import session from "../app/Session"; import routes from "./routes"; import Card from "@mui/material/Card"; import Typography from "@mui/material/Typography"; -import {AccountContext} from "./App"; -import {formatBytes, formatNumber, formatPrice, formatShortDate} from "../app/utils"; -import {Trans, useTranslation} from "react-i18next"; +import { AccountContext } from "./App"; +import { + formatBytes, + formatNumber, + formatPrice, + formatShortDate, +} from "../app/utils"; +import { Trans, useTranslation } from "react-i18next"; import List from "@mui/material/List"; -import {Check, Close} from "@mui/icons-material"; +import { Check, Close } from "@mui/icons-material"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; import Box from "@mui/material/Box"; -import {NavLink} from "react-router-dom"; -import {UnauthorizedError} from "../app/errors"; +import { NavLink } from "react-router-dom"; +import { UnauthorizedError } from "../app/errors"; import DialogContentText from "@mui/material/DialogContentText"; import DialogActions from "@mui/material/DialogActions"; const UpgradeDialog = (props) => { - const { t } = useTranslation(); - const { account } = useContext(AccountContext); // May be undefined! - const [error, setError] = useState(""); - const [tiers, setTiers] = useState(null); - const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR); - const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined - const [loading, setLoading] = useState(false); - const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const { t } = useTranslation(); + const { account } = useContext(AccountContext); // May be undefined! + const [error, setError] = useState(""); + const [tiers, setTiers] = useState(null); + const [interval, setInterval] = useState( + account?.billing?.interval || SubscriptionInterval.YEAR + ); + const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined + const [loading, setLoading] = useState(false); + const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); - useEffect(() => { - const fetchTiers = async () => { - setTiers(await accountApi.billingTiers()); - } - fetchTiers(); // Dangle - }, []); + useEffect(() => { + const fetchTiers = async () => { + setTiers(await accountApi.billingTiers()); + }; + fetchTiers(); // Dangle + }, []); - if (!tiers) { - return <>; + if (!tiers) { + return <>; + } + + const tiersMap = Object.assign( + ...tiers.map((tier) => ({ [tier.code]: tier })) + ); + const newTier = tiersMap[newTierCode]; // May be undefined + const currentTier = account?.tier; // May be undefined + const currentInterval = account?.billing?.interval; // May be undefined + const currentTierCode = currentTier?.code; // May be undefined + + // Figure out buttons, labels and the submit action + let submitAction, submitButtonLabel, banner; + if (!account) { + submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup"); + submitAction = Action.REDIRECT_SIGNUP; + banner = null; + } else if ( + currentTierCode === newTierCode && + (currentInterval === undefined || currentInterval === interval) + ) { + submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); + submitAction = null; + banner = currentTierCode ? Banner.PRORATION_INFO : null; + } else if (!currentTierCode) { + submitButtonLabel = t("account_upgrade_dialog_button_pay_now"); + submitAction = Action.CREATE_SUBSCRIPTION; + banner = null; + } else if (!newTierCode) { + submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription"); + submitAction = Action.CANCEL_SUBSCRIPTION; + banner = Banner.CANCEL_WARNING; + } else { + submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); + submitAction = Action.UPDATE_SUBSCRIPTION; + banner = Banner.PRORATION_INFO; + } + + // Exceptional conditions + if (loading) { + submitAction = null; + } else if ( + newTier?.code && + account?.reservations?.length > newTier?.limits?.reservations + ) { + submitAction = null; + banner = Banner.RESERVATIONS_WARNING; + } + + const handleSubmit = async () => { + if (submitAction === Action.REDIRECT_SIGNUP) { + window.location.href = routes.signup; + return; } - - const tiersMap = Object.assign(...tiers.map(tier => ({[tier.code]: tier}))); - const newTier = tiersMap[newTierCode]; // May be undefined - const currentTier = account?.tier; // May be undefined - const currentInterval = account?.billing?.interval; // May be undefined - const currentTierCode = currentTier?.code; // May be undefined - - // Figure out buttons, labels and the submit action - let submitAction, submitButtonLabel, banner; - if (!account) { - submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup"); - submitAction = Action.REDIRECT_SIGNUP; - banner = null; - } else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) { - submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); - submitAction = null; - banner = (currentTierCode) ? Banner.PRORATION_INFO : null; - } else if (!currentTierCode) { - submitButtonLabel = t("account_upgrade_dialog_button_pay_now"); - submitAction = Action.CREATE_SUBSCRIPTION; - banner = null; - } else if (!newTierCode) { - submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription"); - submitAction = Action.CANCEL_SUBSCRIPTION; - banner = Banner.CANCEL_WARNING; - } else { - submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); - submitAction = Action.UPDATE_SUBSCRIPTION; - banner = Banner.PRORATION_INFO; + try { + setLoading(true); + if (submitAction === Action.CREATE_SUBSCRIPTION) { + const response = await accountApi.createBillingSubscription( + newTierCode, + interval + ); + window.location.href = response.redirect_url; + } else if (submitAction === Action.UPDATE_SUBSCRIPTION) { + await accountApi.updateBillingSubscription(newTierCode, interval); + } else if (submitAction === Action.CANCEL_SUBSCRIPTION) { + await accountApi.deleteBillingSubscription(); + } + props.onCancel(); + } catch (e) { + console.log(`[UpgradeDialog] Error changing billing subscription`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } else { + setError(e.message); + } + } finally { + setLoading(false); } + }; - // Exceptional conditions - if (loading) { - submitAction = null; - } else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) { - submitAction = null; - banner = Banner.RESERVATIONS_WARNING; - } - - const handleSubmit = async () => { - if (submitAction === Action.REDIRECT_SIGNUP) { - window.location.href = routes.signup; - return; - } - try { - setLoading(true); - if (submitAction === Action.CREATE_SUBSCRIPTION) { - const response = await accountApi.createBillingSubscription(newTierCode, interval); - window.location.href = response.redirect_url; - } else if (submitAction === Action.UPDATE_SUBSCRIPTION) { - await accountApi.updateBillingSubscription(newTierCode, interval); - } else if (submitAction === Action.CANCEL_SUBSCRIPTION) { - await accountApi.deleteBillingSubscription(); - } - props.onCancel(); - } catch (e) { - console.log(`[UpgradeDialog] Error changing billing subscription`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } else { - setError(e.message); - } - } finally { - setLoading(false); - } - } - - // Figure out discount - let discount = 0, upto = false; - if (newTier?.prices) { - discount = Math.round(((newTier.prices.month*12/newTier.prices.year)-1)*100); - } else { - let n = 0; - for (const t of tiers) { - if (t.prices) { - const tierDiscount = Math.round(((t.prices.month*12/t.prices.year)-1)*100); - if (tierDiscount > discount) { - discount = tierDiscount; - n++; - } - } - } - upto = n > 1; - } - - return ( - - -
-
{t("account_upgrade_dialog_title")}
-
- {t("account_upgrade_dialog_interval_monthly")} - setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)} - /> - {t("account_upgrade_dialog_interval_yearly")} - {discount > 0 && - - } -
-
-
- -
- {tiers.map(tier => - setNewTierCode(tier.code)} // tier.code may be undefined! - /> - )} -
- {banner === Banner.CANCEL_WARNING && - - - - } - {banner === Banner.PRORATION_INFO && - - - - } - {banner === Banner.RESERVATIONS_WARNING && - - , - }} - /> - - } -
- - - {config.billing_contact.indexOf('@') !== -1 && - <> }}/>{" "} - } - {config.billing_contact.match(`^http?s://`) && - <> }}/>{" "} - } - {error} - - - - - - -
+ // Figure out discount + let discount = 0, + upto = false; + if (newTier?.prices) { + discount = Math.round( + ((newTier.prices.month * 12) / newTier.prices.year - 1) * 100 ); + } else { + let n = 0; + for (const t of tiers) { + if (t.prices) { + const tierDiscount = Math.round( + ((t.prices.month * 12) / t.prices.year - 1) * 100 + ); + if (tierDiscount > discount) { + discount = tierDiscount; + n++; + } + } + } + upto = n > 1; + } + + return ( + + +
+
{t("account_upgrade_dialog_title")}
+
+ + {t("account_upgrade_dialog_interval_monthly")} + + + setInterval( + ev.target.checked + ? SubscriptionInterval.YEAR + : SubscriptionInterval.MONTH + ) + } + /> + + {t("account_upgrade_dialog_interval_yearly")} + + {discount > 0 && ( + + )} +
+
+
+ +
+ {tiers.map((tier) => ( + setNewTierCode(tier.code)} // tier.code may be undefined! + /> + ))} +
+ {banner === Banner.CANCEL_WARNING && ( + + + + )} + {banner === Banner.PRORATION_INFO && ( + + + + )} + {banner === Banner.RESERVATIONS_WARNING && ( + + , + }} + /> + + )} +
+ + + {config.billing_contact.indexOf("@") !== -1 && ( + <> + , + }} + />{" "} + + )} + {config.billing_contact.match(`^http?s://`) && ( + <> + , + }} + />{" "} + + )} + {error} + + + + + + +
+ ); }; const TierCard = (props) => { - const { t } = useTranslation(); - const tier = props.tier; + const { t } = useTranslation(); + const tier = props.tier; - let cardStyle, labelStyle, labelText; - if (props.selected) { - cardStyle = { background: "#eee", border: "3px solid #338574" }; - labelStyle = { background: "#338574", color: "white" }; - labelText = t("account_upgrade_dialog_tier_selected_label"); - } else if (props.current) { - cardStyle = { border: "3px solid #eee" }; - labelStyle = { background: "#eee", color: "black" }; - labelText = t("account_upgrade_dialog_tier_current_label"); - } else { - cardStyle = { border: "3px solid transparent" }; - } + let cardStyle, labelStyle, labelText; + if (props.selected) { + cardStyle = { background: "#eee", border: "3px solid #338574" }; + labelStyle = { background: "#338574", color: "white" }; + labelText = t("account_upgrade_dialog_tier_selected_label"); + } else if (props.current) { + cardStyle = { border: "3px solid #eee" }; + labelStyle = { background: "#eee", color: "black" }; + labelText = t("account_upgrade_dialog_tier_current_label"); + } else { + cardStyle = { border: "3px solid transparent" }; + } - let monthlyPrice; - if (!tier.prices) { - monthlyPrice = 0; - } else if (props.interval === SubscriptionInterval.YEAR) { - monthlyPrice = tier.prices.year/12; - } else if (props.interval === SubscriptionInterval.MONTH) { - monthlyPrice = tier.prices.month; - } + let monthlyPrice; + if (!tier.prices) { + monthlyPrice = 0; + } else if (props.interval === SubscriptionInterval.YEAR) { + monthlyPrice = tier.prices.year / 12; + } else if (props.interval === SubscriptionInterval.MONTH) { + monthlyPrice = tier.prices.month; + } - return ( - - - - - {labelStyle && -
{labelText}
- } - - {tier.name || t("account_basics_tier_free")} - -
- {formatPrice(monthlyPrice)} - {monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}} -
- - {tier.limits.reservations > 0 && {t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations, count: tier.limits.reservations })}} - {t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages), count: tier.limits.messages })} - {t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails), count: tier.limits.emails })} - {tier.limits.calls > 0 && {t("account_upgrade_dialog_tier_features_calls", { calls: formatNumber(tier.limits.calls), count: tier.limits.calls })}} - {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })} - {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} - {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}} - - {tier.prices && props.interval === SubscriptionInterval.MONTH && - - {t("account_upgrade_dialog_tier_price_billed_monthly", { price: formatPrice(tier.prices.month*12) })} - - } - {tier.prices && props.interval === SubscriptionInterval.YEAR && - - {t("account_upgrade_dialog_tier_price_billed_yearly", { price: formatPrice(tier.prices.year), save: formatPrice(tier.prices.month*12-tier.prices.year) })} - - } -
-
-
-
- - ); -} + return ( + + + + + {labelStyle && ( +
+ {labelText} +
+ )} + + {tier.name || t("account_basics_tier_free")} + +
+ + {formatPrice(monthlyPrice)} + + {monthlyPrice > 0 && ( + <>/ {t("account_upgrade_dialog_tier_price_per_month")} + )} +
+ + {tier.limits.reservations > 0 && ( + + {t("account_upgrade_dialog_tier_features_reservations", { + reservations: tier.limits.reservations, + count: tier.limits.reservations, + })} + + )} + + {t("account_upgrade_dialog_tier_features_messages", { + messages: formatNumber(tier.limits.messages), + count: tier.limits.messages, + })} + + + {t("account_upgrade_dialog_tier_features_emails", { + emails: formatNumber(tier.limits.emails), + count: tier.limits.emails, + })} + + {tier.limits.calls > 0 && ( + + {t("account_upgrade_dialog_tier_features_calls", { + calls: formatNumber(tier.limits.calls), + count: tier.limits.calls, + })} + + )} + + {t( + "account_upgrade_dialog_tier_features_attachment_file_size", + { filesize: formatBytes(tier.limits.attachment_file_size, 0) } + )} + + {tier.limits.reservations === 0 && ( + + {t("account_upgrade_dialog_tier_features_no_reservations")} + + )} + {tier.limits.calls === 0 && ( + + {t("account_upgrade_dialog_tier_features_no_calls")} + + )} + + {tier.prices && props.interval === SubscriptionInterval.MONTH && ( + + {t("account_upgrade_dialog_tier_price_billed_monthly", { + price: formatPrice(tier.prices.month * 12), + })} + + )} + {tier.prices && props.interval === SubscriptionInterval.YEAR && ( + + {t("account_upgrade_dialog_tier_price_billed_yearly", { + price: formatPrice(tier.prices.year), + save: formatPrice(tier.prices.month * 12 - tier.prices.year), + })} + + )} +
+
+
+
+ ); +}; const Feature = (props) => { - return {props.children}; -} + return {props.children}; +}; const NoFeature = (props) => { - return {props.children}; -} + return {props.children}; +}; const FeatureItem = (props) => { - return ( - - - {props.feature && } - {!props.feature && } - - - {props.children} - - } - /> - - - ); + return ( + + + {props.feature && } + {!props.feature && } + + {props.children}} + /> + + ); }; const Action = { - REDIRECT_SIGNUP: 1, - CREATE_SUBSCRIPTION: 2, - UPDATE_SUBSCRIPTION: 3, - CANCEL_SUBSCRIPTION: 4 + REDIRECT_SIGNUP: 1, + CREATE_SUBSCRIPTION: 2, + UPDATE_SUBSCRIPTION: 3, + CANCEL_SUBSCRIPTION: 4, }; const Banner = { - CANCEL_WARNING: 1, - PRORATION_INFO: 2, - RESERVATIONS_WARNING: 3 + CANCEL_WARNING: 1, + PRORATION_INFO: 2, + RESERVATIONS_WARNING: 3, }; export default UpgradeDialog; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index b1ce8ff..0fc0204 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -1,7 +1,7 @@ -import {useNavigate, useParams} from "react-router-dom"; -import {useEffect, useState} from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useEffect, useState } from "react"; import subscriptionManager from "../app/SubscriptionManager"; -import {disallowedTopic, expandSecureUrl, topicUrl} from "../app/utils"; +import { disallowedTopic, expandSecureUrl, topicUrl } from "../app/utils"; import notifier from "../app/Notifier"; import routes from "./routes"; import connectionManager from "../app/ConnectionManager"; @@ -9,7 +9,7 @@ import poller from "../app/Poller"; import pruner from "../app/Pruner"; import session from "../app/Session"; import accountApi from "../app/AccountApi"; -import {UnauthorizedError} from "../app/errors"; +import { UnauthorizedError } from "../app/errors"; /** * Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection @@ -17,65 +17,82 @@ import {UnauthorizedError} from "../app/errors"; * to the connection being re-established). */ export const useConnectionListeners = (account, subscriptions, users) => { - const navigate = useNavigate(); + const navigate = useNavigate(); - // Register listeners for incoming messages, and connection state changes - useEffect(() => { - const handleMessage = async (subscriptionId, message) => { - const subscription = await subscriptionManager.get(subscriptionId); - if (subscription.internal) { - await handleInternalMessage(message); - } else { - await handleNotification(subscriptionId, message); - } - }; - - const handleInternalMessage = async (message) => { - console.log(`[ConnectionListener] Received message on sync topic`, message.message); - try { - const data = JSON.parse(message.message); - if (data.event === "sync") { - console.log(`[ConnectionListener] Triggering account sync`); - await accountApi.sync(); - } else { - console.log(`[ConnectionListener] Unknown message type. Doing nothing.`); - } - } catch (e) { - console.log(`[ConnectionListener] Error parsing sync topic message`, e); - } - }; - - const handleNotification = async (subscriptionId, notification) => { - const added = await subscriptionManager.addNotification(subscriptionId, notification); - if (added) { - const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription)); - await notifier.notify(subscriptionId, notification, defaultClickAction) - } - }; - connectionManager.registerStateListener(subscriptionManager.updateState); - connectionManager.registerMessageListener(handleMessage); - return () => { - connectionManager.resetStateListener(); - connectionManager.resetMessageListener(); - } - }, - // We have to disable dep checking for "navigate". This is fine, it never changes. - // eslint-disable-next-line - [] - ); - - // Sync topic listener: For accounts with sync_topic, subscribe to an internal topic - useEffect(() => { - if (!account || !account.sync_topic) { - return; + // Register listeners for incoming messages, and connection state changes + useEffect( + () => { + const handleMessage = async (subscriptionId, message) => { + const subscription = await subscriptionManager.get(subscriptionId); + if (subscription.internal) { + await handleInternalMessage(message); + } else { + await handleNotification(subscriptionId, message); } - subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle! - }, [account]); + }; - // When subscriptions or users change, refresh the connections - useEffect(() => { - connectionManager.refresh(subscriptions, users); // Dangle - }, [subscriptions, users]); + const handleInternalMessage = async (message) => { + console.log( + `[ConnectionListener] Received message on sync topic`, + message.message + ); + try { + const data = JSON.parse(message.message); + if (data.event === "sync") { + console.log(`[ConnectionListener] Triggering account sync`); + await accountApi.sync(); + } else { + console.log( + `[ConnectionListener] Unknown message type. Doing nothing.` + ); + } + } catch (e) { + console.log( + `[ConnectionListener] Error parsing sync topic message`, + e + ); + } + }; + + const handleNotification = async (subscriptionId, notification) => { + const added = await subscriptionManager.addNotification( + subscriptionId, + notification + ); + if (added) { + const defaultClickAction = (subscription) => + navigate(routes.forSubscription(subscription)); + await notifier.notify( + subscriptionId, + notification, + defaultClickAction + ); + } + }; + connectionManager.registerStateListener(subscriptionManager.updateState); + connectionManager.registerMessageListener(handleMessage); + return () => { + connectionManager.resetStateListener(); + connectionManager.resetMessageListener(); + }; + }, + // We have to disable dep checking for "navigate". This is fine, it never changes. + // eslint-disable-next-line + [] + ); + + // Sync topic listener: For accounts with sync_topic, subscribe to an internal topic + useEffect(() => { + if (!account || !account.sync_topic) { + return; + } + subscriptionManager.add(config.base_url, account.sync_topic, true); // Dangle! + }, [account]); + + // When subscriptions or users change, refresh the connections + useEffect(() => { + connectionManager.refresh(subscriptions, users); // Dangle + }, [subscriptions, users]); }; /** @@ -83,35 +100,43 @@ export const useConnectionListeners = (account, subscriptions, users) => { * This will only be run once after the initial page load. */ export const useAutoSubscribe = (subscriptions, selected) => { - const [hasRun, setHasRun] = useState(false); - const params = useParams(); + const [hasRun, setHasRun] = useState(false); + const params = useParams(); - useEffect(() => { - const loaded = subscriptions !== null && subscriptions !== undefined; - if (!loaded || hasRun) { - return; + useEffect(() => { + const loaded = subscriptions !== null && subscriptions !== undefined; + if (!loaded || hasRun) { + return; + } + setHasRun(true); + const eligible = + params.topic && !selected && !disallowedTopic(params.topic); + if (eligible) { + const baseUrl = params.baseUrl + ? expandSecureUrl(params.baseUrl) + : config.base_url; + console.log( + `[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}` + ); + (async () => { + const subscription = await subscriptionManager.add( + baseUrl, + params.topic + ); + if (session.exists()) { + try { + await accountApi.addSubscription(baseUrl, params.topic); + } catch (e) { + console.log(`[Hooks] Auto-subscribing failed`, e); + if (e instanceof UnauthorizedError) { + session.resetAndRedirect(routes.login); + } + } } - setHasRun(true); - const eligible = params.topic && !selected && !disallowedTopic(params.topic); - if (eligible) { - const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : config.base_url; - console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); - (async () => { - const subscription = await subscriptionManager.add(baseUrl, params.topic); - if (session.exists()) { - try { - await accountApi.addSubscription(baseUrl, params.topic); - } catch (e) { - console.log(`[Hooks] Auto-subscribing failed`, e); - if (e instanceof UnauthorizedError) { - session.resetAndRedirect(routes.login); - } - } - } - poller.pollInBackground(subscription); // Dangle! - })(); - } - }, [params, subscriptions, selected, hasRun]); + poller.pollInBackground(subscription); // Dangle! + })(); + } + }, [params, subscriptions, selected, hasRun]); }; /** @@ -120,19 +145,19 @@ export const useAutoSubscribe = (subscriptions, selected) => { * up "unused" imports. See https://github.com/binwiederhier/ntfy/issues/186. */ export const useBackgroundProcesses = () => { - useEffect(() => { - poller.startWorker(); - pruner.startWorker(); - accountApi.startWorker(); - }, []); -} + useEffect(() => { + poller.startWorker(); + pruner.startWorker(); + accountApi.startWorker(); + }, []); +}; export const useAccountListener = (setAccount) => { - useEffect(() => { - accountApi.registerListener(setAccount); - accountApi.sync(); // Dangle - return () => { - accountApi.resetListener(); - } - }, []); -} + useEffect(() => { + accountApi.registerListener(setAccount); + accountApi.sync(); // Dangle + return () => { + accountApi.resetListener(); + }; + }, []); +}; diff --git a/web/src/components/i18n.js b/web/src/components/i18n.js index 42eb572..2bc315c 100644 --- a/web/src/components/i18n.js +++ b/web/src/components/i18n.js @@ -1,7 +1,7 @@ -import i18n from 'i18next'; -import Backend from 'i18next-http-backend'; -import LanguageDetector from 'i18next-browser-languagedetector'; -import { initReactI18next } from 'react-i18next'; +import i18n from "i18next"; +import Backend from "i18next-http-backend"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; // Translations using i18next // - Options: https://www.i18next.com/overview/configuration-options @@ -12,18 +12,18 @@ import { initReactI18next } from 'react-i18next'; // https://github.com/i18next/react-i18next/tree/master/example/react i18n - .use(Backend) - .use(LanguageDetector) - .use(initReactI18next) - .init({ - fallbackLng: 'en', - debug: true, - interpolation: { - escapeValue: false, // not needed for react as it escapes by default - }, - backend: { - loadPath: '/static/langs/{{lng}}.json', - } - }); + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: "en", + debug: true, + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, + backend: { + loadPath: "/static/langs/{{lng}}.json", + }, + }); export default i18n; diff --git a/web/src/components/routes.js b/web/src/components/routes.js index d1db160..17e0eac 100644 --- a/web/src/components/routes.js +++ b/web/src/components/routes.js @@ -1,20 +1,20 @@ import config from "../app/config"; -import {shortUrl} from "../app/utils"; +import { shortUrl } from "../app/utils"; const routes = { - login: "/login", - signup: "/signup", - app: config.app_root, - account: "/account", - settings: "/settings", - subscription: "/:topic", - subscriptionExternal: "/:baseUrl/:topic", - forSubscription: (subscription) => { - if (subscription.baseUrl !== config.base_url) { - return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; - } - return `/${subscription.topic}`; + login: "/login", + signup: "/signup", + app: config.app_root, + account: "/account", + settings: "/settings", + subscription: "/:topic", + subscriptionExternal: "/:baseUrl/:topic", + forSubscription: (subscription) => { + if (subscription.baseUrl !== config.base_url) { + return `/${shortUrl(subscription.baseUrl)}/${subscription.topic}`; } + return `/${subscription.topic}`; + }, }; export default routes; diff --git a/web/src/components/styles.js b/web/src/components/styles.js index d612794..6f1e30b 100644 --- a/web/src/components/styles.js +++ b/web/src/components/styles.js @@ -1,7 +1,7 @@ import Typography from "@mui/material/Typography"; import theme from "./theme"; import Container from "@mui/material/Container"; -import {Backdrop, styled} from "@mui/material"; +import { Backdrop, styled } from "@mui/material"; export const Paragraph = styled(Typography)({ paddingTop: 8, @@ -9,14 +9,14 @@ export const Paragraph = styled(Typography)({ }); export const VerticallyCenteredContainer = styled(Container)({ - display: 'flex', + display: "flex", flexGrow: 1, - flexDirection: 'column', - justifyContent: 'center', - alignContent: 'center', - color: theme.palette.text.primary + flexDirection: "column", + justifyContent: "center", + alignContent: "center", + color: theme.palette.text.primary, }); export const LightboxBackdrop = styled(Backdrop)({ - backgroundColor: 'rgba(0, 0, 0, 0.8)' // was: 0.5 + backgroundColor: "rgba(0, 0, 0, 0.8)", // was: 0.5 }); diff --git a/web/src/components/theme.js b/web/src/components/theme.js index 3fdafae..ca77cdc 100644 --- a/web/src/components/theme.js +++ b/web/src/components/theme.js @@ -1,13 +1,13 @@ -import { red } from '@mui/material/colors'; -import { createTheme } from '@mui/material/styles'; +import { red } from "@mui/material/colors"; +import { createTheme } from "@mui/material/styles"; const theme = createTheme({ palette: { primary: { - main: '#338574', + main: "#338574", }, secondary: { - main: '#6cead0', + main: "#6cead0", }, error: { main: red.A400, @@ -17,19 +17,19 @@ const theme = createTheme({ MuiListItemIcon: { styleOverrides: { root: { - minWidth: '36px', + minWidth: "36px", }, }, }, MuiCardContent: { styleOverrides: { root: { - ':last-child': { - paddingBottom: '16px' - } - } - } - } + ":last-child": { + paddingBottom: "16px", + }, + }, + }, + }, }, }); diff --git a/web/src/index.js b/web/src/index.js index 659bcb8..d60c05a 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -1,6 +1,6 @@ -import * as React from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './components/App'; +import * as React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./components/App"; -const root = createRoot(document.querySelector('#root')); +const root = createRoot(document.querySelector("#root")); root.render(); From a859ed9f58ef9047f65050115404dd7efdbf60cd Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Tue, 23 May 2023 20:57:06 +0200 Subject: [PATCH 53/97] Add .git-blame-ignore-revs --- .git-blame-ignore-revs | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..1e07cd0 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# .git-blame-ignore-revs +# https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view + +# Run prettier (https://github.com/binwiederhier/ntfy/pull/746) +6f6a2d1f693070bf72e89d86748080e4825c9164 From 6f230a796eaff2d509e2111d91ef22907a63054f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 23 May 2023 19:23:34 -0400 Subject: [PATCH 54/97] Release notes --- docs/releases.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index 89ecc51..49bb7fc 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1225,4 +1225,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** * Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting) - +* Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost)) From ca5d736a7169eb6b4b0d849e061d5bf9565dcc53 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 23 May 2023 19:29:47 -0400 Subject: [PATCH 55/97] Line width --- web/.prettierignore | 2 +- web/package.json | 3 + web/public/config.js | 12 +- web/public/index.html | 27 +- web/src/app/AccountApi.js | 25 +- web/src/app/Api.js | 24 +- web/src/app/Connection.js | 68 +-- web/src/app/ConnectionManager.js | 33 +- web/src/app/Notifier.js | 19 +- web/src/app/Poller.js | 10 +- web/src/app/Pruner.js | 7 +- web/src/app/SubscriptionManager.js | 22 +- web/src/app/errors.js | 7 +- web/src/app/utils.js | 45 +- web/src/components/Account.js | 567 ++++++----------------- web/src/components/ActionBar.js | 69 +-- web/src/components/App.js | 75 +-- web/src/components/AvatarBox.js | 6 +- web/src/components/EmojiPicker.js | 52 +-- web/src/components/ErrorBoundary.js | 27 +- web/src/components/Login.js | 19 +- web/src/components/Messaging.js | 53 +-- web/src/components/Navigation.js | 172 ++----- web/src/components/Notifications.js | 210 ++------- web/src/components/Preferences.js | 280 +++-------- web/src/components/PublishDialog.js | 145 +----- web/src/components/ReserveDialogs.js | 75 +-- web/src/components/ReserveTopicSelect.js | 23 +- web/src/components/Signup.js | 23 +- web/src/components/SubscribeDialog.js | 127 +---- web/src/components/SubscriptionPopup.js | 165 ++----- web/src/components/UpgradeDialog.js | 118 +---- web/src/components/hooks.js | 44 +- 33 files changed, 521 insertions(+), 2033 deletions(-) diff --git a/web/.prettierignore b/web/.prettierignore index d0097d3..d50a46c 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -1,2 +1,2 @@ build/ -public/static/langs/ \ No newline at end of file +public/static/langs/ diff --git a/web/package.json b/web/package.json index 1ca2da7..10c198d 100644 --- a/web/package.json +++ b/web/package.json @@ -43,5 +43,8 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "prettier": { + "printWidth": 160 } } diff --git a/web/public/config.js b/web/public/config.js index a748dd8..2f46d65 100644 --- a/web/public/config.js +++ b/web/public/config.js @@ -15,15 +15,5 @@ var config = { enable_emails: true, enable_calls: true, billing_contact: "", - disallowed_topics: [ - "docs", - "static", - "file", - "app", - "account", - "settings", - "signup", - "login", - "v1", - ], + disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"], }; diff --git a/web/public/index.html b/web/public/index.html index 31dd280..e59a62e 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -5,10 +5,7 @@ ntfy web - + @@ -18,11 +15,7 @@ - + @@ -40,23 +33,13 @@ - - + +
diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 3f11611..9af220a 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -56,9 +56,7 @@ class AccountApi { async logout() { const url = accountTokenUrl(config.base_url); - console.log( - `[AccountApi] Logging out from ${url} using token ${session.token()}` - ); + console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`); await fetchOrThrow(url, { method: "DELETE", headers: withBearerAuth({}, session.token()), @@ -227,9 +225,7 @@ class AccountApi { async upsertReservation(topic, everyone) { const url = accountReservationUrl(config.base_url); - console.log( - `[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}` - ); + console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`); await fetchOrThrow(url, { method: "POST", headers: withBearerAuth({}, session.token()), @@ -264,16 +260,12 @@ class AccountApi { } async createBillingSubscription(tier, interval) { - console.log( - `[AccountApi] Creating billing subscription with ${tier} and interval ${interval}` - ); + console.log(`[AccountApi] Creating billing subscription with ${tier} and interval ${interval}`); return await this.upsertBillingSubscription("POST", tier, interval); } async updateBillingSubscription(tier, interval) { - console.log( - `[AccountApi] Updating billing subscription with ${tier} and interval ${interval}` - ); + console.log(`[AccountApi] Updating billing subscription with ${tier} and interval ${interval}`); return await this.upsertBillingSubscription("PUT", tier, interval); } @@ -324,9 +316,7 @@ class AccountApi { async addPhoneNumber(phoneNumber, code) { const url = accountPhoneUrl(config.base_url); - console.log( - `[AccountApi] Adding phone number with verification code ${url}` - ); + console.log(`[AccountApi] Adding phone number with verification code ${url}`); await fetchOrThrow(url, { method: "PUT", headers: withBearerAuth({}, session.token()), @@ -371,10 +361,7 @@ class AccountApi { } } if (account.subscriptions) { - await subscriptionManager.syncFromRemote( - account.subscriptions, - account.reservations - ); + await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations); } return account; } catch (e) { diff --git a/web/src/app/Api.js b/web/src/app/Api.js index 345b0f2..4d7ce82 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -1,12 +1,4 @@ -import { - fetchLinesIterator, - maybeWithAuth, - topicShortUrl, - topicUrl, - topicUrlAuth, - topicUrlJsonPoll, - topicUrlJsonPollWithSince, -} from "./utils"; +import { fetchLinesIterator, maybeWithAuth, topicShortUrl, topicUrl, topicUrlAuth, topicUrlJsonPoll, topicUrlJsonPollWithSince } from "./utils"; import userManager from "./UserManager"; import { fetchOrThrow } from "./errors"; @@ -14,9 +6,7 @@ class Api { async poll(baseUrl, topic, since) { const user = await userManager.get(baseUrl); const shortUrl = topicShortUrl(baseUrl, topic); - const url = since - ? topicUrlJsonPollWithSince(baseUrl, topic, since) - : topicUrlJsonPoll(baseUrl, topic); + const url = since ? topicUrlJsonPollWithSince(baseUrl, topic, since) : topicUrlJsonPoll(baseUrl, topic); const messages = []; const headers = maybeWithAuth({}, user); console.log(`[Api] Polling ${url}`); @@ -73,17 +63,11 @@ class Api { xhr.upload.addEventListener("progress", onProgress); xhr.addEventListener("readystatechange", () => { if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) { - console.log( - `[Api] Publish successful (HTTP ${xhr.status})`, - xhr.response - ); + console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response); resolve(xhr.response); } else if (xhr.readyState === 4) { // Firefox bug; see description above! - console.log( - `[Api] Publish failed (HTTP ${xhr.status})`, - xhr.responseText - ); + console.log(`[Api] Publish failed (HTTP ${xhr.status})`, xhr.responseText); let errorText; try { const error = JSON.parse(xhr.responseText); diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 5dfc41b..2341678 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -1,10 +1,4 @@ -import { - basicAuth, - bearerAuth, - encodeBase64Url, - topicShortUrl, - topicUrlWs, -} from "./utils"; +import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils"; const retryBackoffSeconds = [5, 10, 20, 30, 60, 120]; @@ -15,16 +9,7 @@ const retryBackoffSeconds = [5, 10, 20, 30, 60, 120]; * Incoming messages and state changes are forwarded via listeners. */ class Connection { - constructor( - connectionId, - subscriptionId, - baseUrl, - topic, - user, - since, - onNotification, - onStateChanged - ) { + constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) { this.connectionId = connectionId; this.subscriptionId = subscriptionId; this.baseUrl = baseUrl; @@ -44,78 +29,51 @@ class Connection { // we don't want to re-trigger the main view re-render potentially hundreds of times. const wsUrl = this.wsUrl(); - console.log( - `[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}` - ); + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`); this.ws = new WebSocket(wsUrl); this.ws.onopen = (event) => { - console.log( - `[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, - event - ); + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event); this.retryCount = 0; this.onStateChanged(this.subscriptionId, ConnectionState.Connected); }; this.ws.onmessage = (event) => { - console.log( - `[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}` - ); + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`); try { const data = JSON.parse(event.data); if (data.event === "open") { return; } - const relevantAndValid = - data.event === "message" && - "id" in data && - "time" in data && - "message" in data; + const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data; if (!relevantAndValid) { - console.log( - `[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.` - ); + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`); return; } this.since = data.id; this.onNotification(this.subscriptionId, data); } catch (e) { - console.log( - `[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}` - ); + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`); } }; this.ws.onclose = (event) => { if (event.wasClean) { - console.log( - `[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}` - ); + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`); this.ws = null; } else { - const retrySeconds = - retryBackoffSeconds[ - Math.min(this.retryCount, retryBackoffSeconds.length - 1) - ]; + const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length - 1)]; this.retryCount++; - console.log( - `[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds` - ); + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`); this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000); this.onStateChanged(this.subscriptionId, ConnectionState.Connecting); } }; this.ws.onerror = (event) => { - console.log( - `[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, - event - ); + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event); }; } close() { - console.log( - `[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection` - ); + console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`); const socket = this.ws; const retryTimeout = this.retryTimeout; if (socket !== null) { diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js index ced32d5..15b94cd 100644 --- a/web/src/app/ConnectionManager.js +++ b/web/src/app/ConnectionManager.js @@ -49,12 +49,8 @@ class ConnectionManager { return { ...s, user, connectionId }; }) ); - const targetIds = subscriptionsWithUsersAndConnectionId.map( - (s) => s.connectionId - ); - const deletedIds = Array.from(this.connections.keys()).filter( - (id) => !targetIds.includes(id) - ); + const targetIds = subscriptionsWithUsersAndConnectionId.map((s) => s.connectionId); + const deletedIds = Array.from(this.connections.keys()).filter((id) => !targetIds.includes(id)); // Create and add new connections subscriptionsWithUsersAndConnectionId.forEach((subscription) => { @@ -73,15 +69,12 @@ class ConnectionManager { topic, user, since, - (subscriptionId, notification) => - this.notificationReceived(subscriptionId, notification), + (subscriptionId, notification) => this.notificationReceived(subscriptionId, notification), (subscriptionId, state) => this.stateChanged(subscriptionId, state) ); this.connections.set(connectionId, connection); console.log( - `[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${ - user ? user.username : "anonymous" - })` + `[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})` ); connection.start(); } @@ -101,10 +94,7 @@ class ConnectionManager { try { this.stateListener(subscriptionId, state); } catch (e) { - console.error( - `[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, - e - ); + console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e); } } } @@ -114,23 +104,14 @@ class ConnectionManager { try { this.messageListener(subscriptionId, notification); } catch (e) { - console.error( - `[ConnectionManager] Error handling notification for ${subscriptionId}`, - e - ); + console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e); } } } } const makeConnectionId = async (subscription, user) => { - return user - ? hashCode( - `${subscription.id}|${user.username}|${user.password ?? ""}|${ - user.token ?? "" - }` - ) - : hashCode(`${subscription.id}`); + return user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`); }; const connectionManager = new ConnectionManager(); diff --git a/web/src/app/Notifier.js b/web/src/app/Notifier.js index e4396d2..2d00dea 100644 --- a/web/src/app/Notifier.js +++ b/web/src/app/Notifier.js @@ -1,11 +1,4 @@ -import { - formatMessage, - formatTitleWithDefault, - openUrl, - playSound, - topicDisplayName, - topicShortUrl, -} from "./utils"; +import { formatMessage, formatTitleWithDefault, openUrl, playSound, topicDisplayName, topicShortUrl } from "./utils"; import prefs from "./Prefs"; import subscriptionManager from "./SubscriptionManager"; import logo from "../img/ntfy.png"; @@ -30,9 +23,7 @@ class Notifier { const title = formatTitleWithDefault(notification, displayName); // Show notification - console.log( - `[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}` - ); + console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`); const n = new Notification(title, { body: message, icon: logo, @@ -96,11 +87,7 @@ class Notifier { * is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification */ contextSupported() { - return ( - location.protocol === "https:" || - location.hostname.match("^127.") || - location.hostname === "localhost" - ); + return location.protocol === "https:" || location.hostname.match("^127.") || location.hostname === "localhost"; } } diff --git a/web/src/app/Poller.js b/web/src/app/Poller.js index d2bf696..402e36b 100644 --- a/web/src/app/Poller.js +++ b/web/src/app/Poller.js @@ -34,18 +34,12 @@ class Poller { console.log(`[Poller] Polling ${subscription.id}`); const since = subscription.last; - const notifications = await api.poll( - subscription.baseUrl, - subscription.topic, - since - ); + const notifications = await api.poll(subscription.baseUrl, subscription.topic, since); if (!notifications || notifications.length === 0) { console.log(`[Poller] No new notifications found for ${subscription.id}`); return; } - console.log( - `[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}` - ); + console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`); await subscriptionManager.addNotifications(subscription.id, notifications); } diff --git a/web/src/app/Pruner.js b/web/src/app/Pruner.js index 84853b6..498c156 100644 --- a/web/src/app/Pruner.js +++ b/web/src/app/Pruner.js @@ -20,15 +20,12 @@ class Pruner { async prune() { const deleteAfterSeconds = await prefs.deleteAfter(); - const pruneThresholdTimestamp = - Math.round(Date.now() / 1000) - deleteAfterSeconds; + const pruneThresholdTimestamp = Math.round(Date.now() / 1000) - deleteAfterSeconds; if (deleteAfterSeconds === 0) { console.log(`[Pruner] Pruning is disabled. Skipping.`); return; } - console.log( - `[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})` - ); + console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`); try { await subscriptionManager.pruneNotifications(pruneThresholdTimestamp); } catch (e) { diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index 25d0830..a539362 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -7,9 +7,7 @@ class SubscriptionManager { const subscriptions = await db.subscriptions.toArray(); await Promise.all( subscriptions.map(async (s) => { - s.new = await db.notifications - .where({ subscriptionId: s.id, new: 1 }) - .count(); + s.new = await db.notifications.where({ subscriptionId: s.id, new: 1 }).count(); }) ); return subscriptions; @@ -38,20 +36,14 @@ class SubscriptionManager { } async syncFromRemote(remoteSubscriptions, remoteReservations) { - console.log( - `[SubscriptionManager] Syncing subscriptions from remote`, - remoteSubscriptions - ); + console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions); // Add remote subscriptions let remoteIds = []; // = topicUrl(baseUrl, topic) for (let i = 0; i < remoteSubscriptions.length; i++) { const remote = remoteSubscriptions[i]; const local = await this.add(remote.base_url, remote.topic, false); - const reservation = - remoteReservations?.find( - (r) => remote.base_url === config.base_url && remote.topic === r.topic - ) || null; + const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null; await this.update(local.id, { displayName: remote.display_name, // May be undefined reservation: reservation, // May be null! @@ -122,9 +114,7 @@ class SubscriptionManager { /** Adds/replaces notifications, will not throw if they exist */ async addNotifications(subscriptionId, notifications) { - const notificationsWithSubscriptionId = notifications.map( - (notification) => ({ ...notification, subscriptionId }) - ); + const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId })); const lastNotificationId = notifications.at(-1).id; await db.notifications.bulkPut(notificationsWithSubscriptionId); await db.subscriptions.update(subscriptionId, { @@ -158,9 +148,7 @@ class SubscriptionManager { } async markNotificationsRead(subscriptionId) { - await db.notifications - .where({ subscriptionId: subscriptionId, new: 1 }) - .modify({ new: 0 }); + await db.notifications.where({ subscriptionId: subscriptionId, new: 1 }).modify({ new: 0 }); } async setMutedUntil(subscriptionId, mutedUntil) { diff --git a/web/src/app/errors.js b/web/src/app/errors.js index 96aaf86..e31949d 100644 --- a/web/src/app/errors.js +++ b/web/src/app/errors.js @@ -15,12 +15,7 @@ export const throwAppError = async (response) => { } const error = await maybeToJson(response); if (error?.code) { - console.log( - `[Error] HTTP ${response.status}, ntfy error ${error.code}: ${ - error.error || "" - }`, - response - ); + console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response); if (error.code === UserExistsError.CODE) { throw new UserExistsError(); } else if (error.code === TopicReservedError.CODE) { diff --git a/web/src/app/utils.js b/web/src/app/utils.js index f67c2d4..d6bb02d 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -10,37 +10,23 @@ import config from "./config"; import { Base64 } from "js-base64"; export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`; -export const topicUrlWs = (baseUrl, topic) => - `${topicUrl(baseUrl, topic)}/ws` - .replaceAll("https://", "wss://") - .replaceAll("http://", "ws://"); -export const topicUrlJson = (baseUrl, topic) => - `${topicUrl(baseUrl, topic)}/json`; -export const topicUrlJsonPoll = (baseUrl, topic) => - `${topicUrlJson(baseUrl, topic)}?poll=1`; -export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => - `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`; -export const topicUrlAuth = (baseUrl, topic) => - `${topicUrl(baseUrl, topic)}/auth`; -export const topicShortUrl = (baseUrl, topic) => - shortUrl(topicUrl(baseUrl, topic)); +export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://"); +export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`; +export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`; +export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`; +export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; +export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`; export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`; export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`; export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`; -export const accountSubscriptionUrl = (baseUrl) => - `${baseUrl}/v1/account/subscription`; -export const accountReservationUrl = (baseUrl) => - `${baseUrl}/v1/account/reservation`; -export const accountReservationSingleUrl = (baseUrl, topic) => - `${baseUrl}/v1/account/reservation/${topic}`; -export const accountBillingSubscriptionUrl = (baseUrl) => - `${baseUrl}/v1/account/billing/subscription`; -export const accountBillingPortalUrl = (baseUrl) => - `${baseUrl}/v1/account/billing/portal`; +export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`; +export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reservation`; +export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`; +export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`; +export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; -export const accountPhoneVerifyUrl = (baseUrl) => - `${baseUrl}/v1/account/phone/verify`; +export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`; export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; @@ -208,9 +194,7 @@ export const formatShortDateTime = (timestamp) => { }; export const formatShortDate = (timestamp) => { - return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format( - new Date(timestamp * 1000) - ); + return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000)); }; export const formatBytes = (bytes, decimals = 2) => { @@ -312,8 +296,7 @@ export async function* fetchLinesIterator(fileURL, headers) { } export const randomAlphanumericString = (len) => { - const alphabet = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let id = ""; for (let i = 0; i < len; i++) { id += alphabet[(Math.random() * alphabet.length) | 0]; diff --git a/web/src/components/Account.js b/web/src/components/Account.js index bb8e7a7..b6710c6 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -38,18 +38,8 @@ import DialogContent from "@mui/material/DialogContent"; import TextField from "@mui/material/TextField"; import routes from "./routes"; import IconButton from "@mui/material/IconButton"; -import { - formatBytes, - formatShortDate, - formatShortDateTime, - openUrl, -} from "../app/utils"; -import accountApi, { - LimitBasis, - Role, - SubscriptionInterval, - SubscriptionStatus, -} from "../app/AccountApi"; +import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils"; +import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import { Pref, PrefGroup } from "./Pref"; import db from "../app/db"; @@ -108,11 +98,7 @@ const Username = () => { const labelId = "prefUsername"; return ( - +
{session.username()} {account?.role === Role.ADMIN ? ( @@ -146,30 +132,16 @@ const ChangePassword = () => { }; return ( - +
- + ⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤ - +
- +
); }; @@ -190,9 +162,7 @@ const ChangePasswordDialog = (props) => { } catch (e) { console.log(`[Account] Error changing password`, e); if (e instanceof IncorrectPasswordError) { - setError( - t("account_basics_password_dialog_current_password_incorrect") - ); + setError(t("account_basics_password_dialog_current_password_incorrect")); } else if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); } else { @@ -209,9 +179,7 @@ const ChangePasswordDialog = (props) => { margin="dense" id="current-password" label={t("account_basics_password_dialog_current_password_label")} - aria-label={t( - "account_basics_password_dialog_current_password_label" - )} + aria-label={t("account_basics_password_dialog_current_password_label")} type="password" value={currentPassword} onChange={(ev) => setCurrentPassword(ev.target.value)} @@ -233,9 +201,7 @@ const ChangePasswordDialog = (props) => { margin="dense" id="confirm" label={t("account_basics_password_dialog_confirm_password_label")} - aria-label={t( - "account_basics_password_dialog_confirm_password_label" - )} + aria-label={t("account_basics_password_dialog_confirm_password_label")} type="password" value={confirmPassword} onChange={(ev) => setConfirmPassword(ev.target.value)} @@ -245,14 +211,7 @@ const ChangePasswordDialog = (props) => { - @@ -299,9 +258,7 @@ const AccountType = () => { : t("account_basics_tier_admin_suffix_no_tier"); accountType = `${t("account_basics_tier_admin")} ${tierSuffix}`; } else if (!account.tier) { - accountType = config.enable_payments - ? t("account_basics_tier_free") - : t("account_basics_tier_basic"); + accountType = config.enable_payments ? t("account_basics_tier_free") : t("account_basics_tier_basic"); } else { accountType = account.tier.name; if (account.billing?.interval === SubscriptionInterval.MONTH) { @@ -313,10 +270,7 @@ const AccountType = () => { return ( 0 - } + alignTop={account.billing?.status === SubscriptionStatus.PAST_DUE || account.billing?.cancel_at > 0} title={t("account_basics_tier_title")} description={t("account_basics_tier_description")} > @@ -333,49 +287,23 @@ const AccountType = () => { )} - {config.enable_payments && - account.role === Role.USER && - !account.billing?.subscription && ( - - )} - {config.enable_payments && - account.role === Role.USER && - account.billing?.subscription && ( - - )} - {config.enable_payments && - account.role === Role.USER && - account.billing?.customer && ( - - )} + {config.enable_payments && account.role === Role.USER && !account.billing?.subscription && ( + + )} + {config.enable_payments && account.role === Role.USER && account.billing?.subscription && ( + + )} + {config.enable_payments && account.role === Role.USER && account.billing?.customer && ( + + )} {config.enable_payments && ( - setUpgradeDialogOpen(false)} - /> + setUpgradeDialogOpen(false)} /> )}
{account.billing?.status === SubscriptionStatus.PAST_DUE && ( @@ -456,11 +384,7 @@ const PhoneNumbers = () => { } return ( - +
{account?.phone_numbers?.map((phoneNumber) => ( { onDelete={() => handleDelete(phoneNumber)} /> ))} - {!account?.phone_numbers && ( - {t("account_basics_phone_numbers_no_phone_numbers_yet")} - )} + {!account?.phone_numbers && {t("account_basics_phone_numbers_no_phone_numbers_yet")}}
- + { return ( - - {t("account_basics_phone_numbers_dialog_title")} - + {t("account_basics_phone_numbers_dialog_title")} - - {t("account_basics_phone_numbers_dialog_description")} - + {t("account_basics_phone_numbers_dialog_description")} {!verificationCodeSent && (
setPhoneNumber(ev.target.value)} @@ -585,28 +497,15 @@ const AddPhoneNumberDialog = (props) => { sx={{ flexGrow: 1 }} /> - + setChannel(e.target.value)} - /> - } + control={ setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_sms")} /> setChannel(e.target.value)} - /> - } + control={ setChannel(e.target.value)} />} label={t("account_basics_phone_numbers_dialog_channel_call")} sx={{ marginRight: 0 }} /> @@ -619,9 +518,7 @@ const AddPhoneNumberDialog = (props) => { margin="dense" label={t("account_basics_phone_numbers_dialog_code_label")} aria-label={t("account_basics_phone_numbers_dialog_code_label")} - placeholder={t( - "account_basics_phone_numbers_dialog_code_placeholder" - )} + placeholder={t("account_basics_phone_numbers_dialog_code_placeholder")} type="text" value={code} onChange={(ev) => setCode(ev.target.value)} @@ -632,21 +529,11 @@ const AddPhoneNumberDialog = (props) => { )} - - +
@@ -687,14 +574,7 @@ const Stats = () => {
0 - ? normalize( - account.stats.reservations, - account.limits.reservations - ) - : 100 - } + value={account.role === Role.USER && account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} /> )} @@ -722,14 +602,7 @@ const Stats = () => { : t("account_usage_unlimited")}
- +
{config.enable_emails && ( { : t("account_usage_unlimited")} + + + )} + {config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && ( + + {t("account_usage_calls_title")} + + + + + + + } + > +
+ + {account.stats.calls.toLocaleString()} + + + {account.role === Role.USER + ? t("account_usage_of_limit", { + limit: account.limits.calls.toLocaleString(), + }) + : t("account_usage_unlimited")} + +
0 ? normalize(account.stats.calls, account.limits.calls) : 100} />
)} - {config.enable_calls && - (account.role === Role.ADMIN || account.limits.calls > 0) && ( - - {t("account_usage_calls_title")} - - - - - - - } - > -
- - {account.stats.calls.toLocaleString()} - - - {account.role === Role.USER - ? t("account_usage_of_limit", { - limit: account.limits.calls.toLocaleString(), - }) - : t("account_usage_unlimited")} - -
- 0 - ? normalize(account.stats.calls, account.limits.calls) - : 100 - } - /> -
- )}
@@ -830,49 +688,36 @@ const Stats = () => {
- {config.enable_reservations && - account.role === Role.USER && - account.limits.reservations === 0 && ( - - {t("account_usage_reservations_title")} - {config.enable_payments && } - - } - > - {t("account_usage_reservations_none")} - - )} - {config.enable_calls && - account.role === Role.USER && - account.limits.calls === 0 && ( - - {t("account_usage_calls_title")} - {config.enable_payments && } - - } - > - {t("account_usage_calls_none")} - - )} + {config.enable_reservations && account.role === Role.USER && account.limits.reservations === 0 && ( + + {t("account_usage_reservations_title")} + {config.enable_payments && } + + } + > + {t("account_usage_reservations_none")} + + )} + {config.enable_calls && account.role === Role.USER && account.limits.calls === 0 && ( + + {t("account_usage_calls_title")} + {config.enable_payments && } + + } + > + {t("account_usage_calls_none")} + + )} {account.role === Role.USER && account.limits.basis === LimitBasis.IP && ( - - {t("account_usage_basis_ip_description")} - + {t("account_usage_basis_ip_description")} )} ); @@ -928,15 +773,9 @@ const Tokens = () => { {tokens?.length > 0 && } - + - + ); }; @@ -984,9 +823,7 @@ const TokensTable = (props) => { - - {t("account_tokens_table_token_header")} - + {t("account_tokens_table_token_header")} {t("account_tokens_table_label_header")} {t("account_tokens_table_expires_header")} {t("account_tokens_table_last_access_header")} @@ -995,25 +832,12 @@ const TokensTable = (props) => { {tokens.map((token) => ( - - + + - - {token.token.slice(0, 12)} - + {token.token.slice(0, 12)} ... - + handleCopy(token.token)}> @@ -1021,25 +845,13 @@ const TokensTable = (props) => { - {token.token === session.token() && ( - {t("account_tokens_table_current_session")} - )} + {token.token === session.token() && {t("account_tokens_table_current_session")}} {token.token !== session.token() && (token.label || "-")} - - {token.expires ? ( - formatShortDateTime(token.expires) - ) : ( - {t("account_tokens_table_never_expires")} - )} + + {token.expires ? formatShortDateTime(token.expires) : {t("account_tokens_table_never_expires")}} - +
{formatShortDateTime(token.last_access)} { ip: token.last_origin, })} > - - openUrl( - `https://whatismyipaddress.com/ip/${token.last_origin}` - ) - } - > + openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}> @@ -1062,24 +868,16 @@ const TokensTable = (props) => { {token.token !== session.token() && ( <> - handleEditClick(token)} - aria-label={t("account_tokens_dialog_title_edit")} - > + handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> - handleDeleteClick(token)} - aria-label={t("account_tokens_dialog_title_delete")} - > + handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}> )} {token.token === session.token() && ( - + @@ -1095,24 +893,10 @@ const TokensTable = (props) => { ))} - setSnackOpen(false)} - message={t("account_tokens_table_copied_to_clipboard")} - /> + setSnackOpen(false)} message={t("account_tokens_table_copied_to_clipboard")} /> - - + +
); }; @@ -1144,18 +928,8 @@ const TokenDialog = (props) => { }; return ( - - - {editMode - ? t("account_tokens_dialog_title_edit") - : t("account_tokens_dialog_title_create")} - + + {editMode ? t("account_tokens_dialog_title_edit") : t("account_tokens_dialog_title_create")} { variant="standard" /> - setExpires(ev.target.value)} aria-label={t("account_tokens_dialog_expires_label")}> + {editMode && {t("account_tokens_dialog_expires_unchanged")}} + {t("account_tokens_dialog_expires_never")} + {t("account_tokens_dialog_expires_x_hours", { hours: 6 })} + {t("account_tokens_dialog_expires_x_hours", { hours: 12 })} + {t("account_tokens_dialog_expires_x_days", { days: 3 })} + {t("account_tokens_dialog_expires_x_days", { days: 7 })} + {t("account_tokens_dialog_expires_x_days", { days: 30 })} + {t("account_tokens_dialog_expires_x_days", { days: 90 })} + {t("account_tokens_dialog_expires_x_days", { days: 180 })} - - + + ); @@ -1285,26 +1029,13 @@ const DeleteAccount = () => { }; return ( - +
-
- +
); }; @@ -1325,9 +1056,7 @@ const DeleteAccountDialog = (props) => { } catch (e) { console.log(`[Account] Error deleting account`, e); if (e instanceof IncorrectPasswordError) { - setError( - t("account_basics_password_dialog_current_password_incorrect") - ); + setError(t("account_basics_password_dialog_current_password_incorrect")); } else if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); } else { @@ -1340,9 +1069,7 @@ const DeleteAccountDialog = (props) => { {t("account_delete_title")} - - {t("account_delete_dialog_description")} - + {t("account_delete_dialog_description")} { )} - - + diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index b6c8416..2d44014 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -51,8 +51,7 @@ const ActionBar = (props) => { { {title} - {props.selected && ( - - )} + {props.selected && } @@ -101,34 +95,13 @@ const SettingsIcons = (props) => { return ( <> - - {subscription.mutedUntil ? ( - - ) : ( - - )} + + {subscription.mutedUntil ? : } - setAnchorEl(ev.currentTarget)} - aria-label={t("action_bar_toggle_action_menu")} - > + setAnchorEl(ev.currentTarget)} aria-label={t("action_bar_toggle_action_menu")}> - setAnchorEl(null)} - /> + setAnchorEl(null)} /> ); }; @@ -159,43 +132,21 @@ const ProfileIcon = () => { return ( <> {session.exists() && ( - + )} {!session.exists() && config.enable_login && ( - )} {!session.exists() && config.enable_signup && ( - )} - + navigate(routes.account)}> diff --git a/web/src/components/App.js b/web/src/components/App.js index b2c204a..50f2ad6 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -1,11 +1,5 @@ import * as React from "react"; -import { - createContext, - Suspense, - useContext, - useEffect, - useState, -} from "react"; +import { createContext, Suspense, useContext, useEffect, useState } from "react"; import Box from "@mui/material/Box"; import { ThemeProvider } from "@mui/material/styles"; import CssBaseline from "@mui/material/CssBaseline"; @@ -19,21 +13,11 @@ import Preferences from "./Preferences"; import { useLiveQuery } from "dexie-react-hooks"; import subscriptionManager from "../app/SubscriptionManager"; import userManager from "../app/UserManager"; -import { - BrowserRouter, - Outlet, - Route, - Routes, - useParams, -} from "react-router-dom"; +import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom"; import { expandUrl } from "../app/utils"; import ErrorBoundary from "./ErrorBoundary"; import routes from "./routes"; -import { - useAccountListener, - useBackgroundProcesses, - useConnectionListeners, -} from "./hooks"; +import { useAccountListener, useBackgroundProcesses, useConnectionListeners } from "./hooks"; import PublishDialog from "./PublishDialog"; import Messaging from "./Messaging"; import "./i18n"; // Translations! @@ -60,14 +44,8 @@ const App = () => { } /> } /> } /> - } - /> - } - /> + } /> + } /> @@ -82,22 +60,15 @@ const Layout = () => { const params = useParams(); const { account, setAccount } = useContext(AccountContext); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); - const [notificationsGranted, setNotificationsGranted] = useState( - notifier.granted() - ); + const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted()); const [sendDialogOpenMode, setSendDialogOpenMode] = useState(""); const users = useLiveQuery(() => userManager.all()); const subscriptions = useLiveQuery(() => subscriptionManager.all()); - const subscriptionsWithoutInternal = subscriptions?.filter( - (s) => !s.internal - ); - const newNotificationsCount = - subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; + const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal); + const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; const [selected] = (subscriptionsWithoutInternal || []).filter((s) => { return ( - (params.baseUrl && - expandUrl(params.baseUrl).includes(s.baseUrl) && - params.topic === s.topic) || + (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) || (config.base_url === s.baseUrl && params.topic === s.topic) ); }); @@ -109,10 +80,7 @@ const Layout = () => { return ( - setMobileDrawerOpen(!mobileDrawerOpen)} - /> + setMobileDrawerOpen(!mobileDrawerOpen)} /> { mobileDrawerOpen={mobileDrawerOpen} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} onNotificationGranted={setNotificationsGranted} - onPublishMessageClick={() => - setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT) - } + onPublishMessageClick={() => setSendDialogOpenMode(PublishDialog.OPEN_MODE_DEFAULT)} />
@@ -133,11 +99,7 @@ const Layout = () => { }} />
- +
); }; @@ -155,10 +117,7 @@ const Main = (props) => { width: { sm: `calc(100% - ${Navigation.width}px)` }, height: "100vh", overflow: "auto", - backgroundColor: (theme) => - theme.palette.mode === "light" - ? theme.palette.grey[100] - : theme.palette.grey[900], + backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]), }} > {props.children} @@ -171,10 +130,7 @@ const Loader = () => ( open={true} sx={{ zIndex: 100000, - backgroundColor: (theme) => - theme.palette.mode === "light" - ? theme.palette.grey[100] - : theme.palette.grey[900], + backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]), }} > @@ -182,8 +138,7 @@ const Loader = () => ( ); const updateTitle = (newNotificationsCount) => { - document.title = - newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy"; + document.title = newNotificationsCount > 0 ? `(${newNotificationsCount}) ntfy` : "ntfy"; }; export default App; diff --git a/web/src/components/AvatarBox.js b/web/src/components/AvatarBox.js index 5a612f1..506ae63 100644 --- a/web/src/components/AvatarBox.js +++ b/web/src/components/AvatarBox.js @@ -16,11 +16,7 @@ const AvatarBox = (props) => { height: "100vh", }} > - + {props.children} ); diff --git a/web/src/components/EmojiPicker.js b/web/src/components/EmojiPicker.js index 03badb7..3f9f4df 100644 --- a/web/src/components/EmojiPicker.js +++ b/web/src/components/EmojiPicker.js @@ -17,8 +17,7 @@ import { useTranslation } from "react-i18next"; // This is a hack, but on Ubuntu 18.04, with Chrome 99, only Emoji <= 11 are supported. const emojisByCategory = {}; -const isDesktopChrome = - /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent); +const isDesktopChrome = /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent); const maxSupportedVersionForDesktopChrome = 11; rawEmojis.forEach((emoji) => { if (!emojisByCategory[emoji.category]) { @@ -26,12 +25,9 @@ rawEmojis.forEach((emoji) => { } try { const unicodeVersion = parseFloat(emoji.unicode_version); - const supportedEmoji = - unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; + const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; if (supportedEmoji) { - const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join( - " " - )} ${emoji.tags.join(" ")}`; + const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`; const emojiWithSearchBase = { ...emoji, searchBase: searchBase }; emojisByCategory[emoji.category].push(emojiWithSearchBase); } @@ -53,13 +49,7 @@ const EmojiPicker = (props) => { }; return ( - + {({ TransitionProps }) => ( @@ -92,16 +82,8 @@ const EmojiPicker = (props) => { }} InputProps={{ endAdornment: ( - - + + @@ -117,13 +99,7 @@ const EmojiPicker = (props) => { }} > {Object.keys(emojisByCategory).map((category) => ( - + ))} @@ -144,12 +120,7 @@ const Category = (props) => { )} {props.emojis.map((emoji) => ( - props.onPick(emoji.aliases[0])} - /> + props.onPick(emoji.aliases[0])} /> ))} ); @@ -160,12 +131,7 @@ const Emoji = (props) => { const matches = emojiMatches(emoji, props.search); const title = `${emoji.description} (${emoji.aliases[0]})`; return ( - + {props.emoji.emoji} ); diff --git a/web/src/components/ErrorBoundary.js b/web/src/components/ErrorBoundary.js index f1ce7c2..2928218 100644 --- a/web/src/components/ErrorBoundary.js +++ b/web/src/components/ErrorBoundary.js @@ -22,9 +22,7 @@ class ErrorBoundaryImpl extends React.Component { // - https://github.com/dexie/Dexie.js/issues/312 // - https://bugzilla.mozilla.org/show_bug.cgi?id=781982 const isUnsupportedIndexedDB = - error?.name === "InvalidStateError" || - (error?.name === "DatabaseClosedError" && - error?.message?.indexOf("InvalidStateError") !== -1); + error?.name === "InvalidStateError" || (error?.name === "DatabaseClosedError" && error?.message?.indexOf("InvalidStateError") !== -1); if (isUnsupportedIndexedDB) { this.handleUnsupportedIndexedDB(); @@ -48,14 +46,7 @@ class ErrorBoundaryImpl extends React.Component { // Fetch additional info and a better stack trace StackTrace.fromError(error).then((stack) => { console.error("[ErrorBoundary] Stacktrace fetched", stack); - const niceStack = - `${error.toString()}\n` + - stack - .map( - (el) => - ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})` - ) - .join("\n"); + const niceStack = `${error.toString()}\n` + stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n"); this.setState({ niceStack }); }); } @@ -96,9 +87,7 @@ class ErrorBoundaryImpl extends React.Component { - ), + githubLink: , discordLink: , matrixLink: , }} @@ -117,9 +106,7 @@ class ErrorBoundaryImpl extends React.Component { - ), + githubLink: , discordLink: , matrixLink: , }} @@ -135,11 +122,7 @@ class ErrorBoundaryImpl extends React.Component {
{this.state.niceStack}
) : ( <> - {" "} - {t("error_boundary_gathering_info")} + {t("error_boundary_gathering_info")} )}
{this.state.originalStack}
diff --git a/web/src/components/Login.js b/web/src/components/Login.js index a109ae6..ce4f3b5 100644 --- a/web/src/components/Login.js +++ b/web/src/components/Login.js @@ -28,9 +28,7 @@ const Login = () => { const user = { username, password }; try { const token = await accountApi.login(user); - console.log( - `[Login] User auth for user ${user.username} successful, token is ${token}` - ); + console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); session.store(user.username, token); window.location.href = routes.app; } catch (e) { @@ -52,12 +50,7 @@ const Login = () => { return ( {t("login_title")} - + { ), }} /> - {error && ( diff --git a/web/src/components/Messaging.js b/web/src/components/Messaging.js index d69bc57..2fa7ed5 100644 --- a/web/src/components/Messaging.js +++ b/web/src/components/Messaging.js @@ -29,14 +29,7 @@ const Messaging = (props) => { return ( <> - {subscription && ( - - )} + {subscription && } { topic={subscription?.topic ?? ""} message={message} onClose={handleDialogClose} - onDragEnter={() => - props.onDialogOpenModeChange((prev) => - prev ? prev : PublishDialog.OPEN_MODE_DRAG - ) - } // Only update if not already open - onResetOpenMode={() => - props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT) - } + onDragEnter={() => props.onDialogOpenModeChange((prev) => (prev ? prev : PublishDialog.OPEN_MODE_DRAG))} // Only update if not already open + onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)} /> ); @@ -63,11 +50,7 @@ const MessageBar = (props) => { const [snackOpen, setSnackOpen] = useState(false); const handleSendClick = async () => { try { - await api.publish( - subscription.baseUrl, - subscription.topic, - props.message - ); + await api.publish(subscription.baseUrl, subscription.topic, props.message); } catch (e) { console.log(`[MessageBar] Error publishing message`, e); setSnackOpen(true); @@ -84,19 +67,10 @@ const MessageBar = (props) => { right: 0, padding: 2, width: { xs: "100%", sm: `calc(100% - ${Navigation.width}px)` }, - backgroundColor: (theme) => - theme.palette.mode === "light" - ? theme.palette.grey[100] - : theme.palette.grey[900], + backgroundColor: (theme) => (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]), }} > - + { } }} /> - + - setSnackOpen(false)} - message={t("message_bar_error_publishing")} - /> + setSnackOpen(false)} message={t("message_bar_error_publishing")} /> ); diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js index 654e29b..922d6fe 100644 --- a/web/src/components/Navigation.js +++ b/web/src/components/Navigation.js @@ -12,16 +12,7 @@ import List from "@mui/material/List"; import SettingsIcon from "@mui/icons-material/Settings"; import AddIcon from "@mui/icons-material/Add"; import SubscribeDialog from "./SubscribeDialog"; -import { - Alert, - AlertTitle, - Badge, - CircularProgress, - Link, - ListSubheader, - Portal, - Tooltip, -} from "@mui/material"; +import { Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Portal, Tooltip } from "@mui/material"; import Button from "@mui/material/Button"; import Typography from "@mui/material/Typography"; import { openUrl, topicDisplayName, topicUrl } from "../app/utils"; @@ -29,12 +20,7 @@ import routes from "./routes"; import { ConnectionState } from "../app/Connection"; import { useLocation, useNavigate } from "react-router-dom"; import subscriptionManager from "../app/SubscriptionManager"; -import { - ChatBubble, - MoreVert, - NotificationsOffOutlined, - Send, -} from "@mui/icons-material"; +import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material"; import Box from "@mui/material/Box"; import notifier from "../app/Notifier"; import config from "../app/config"; @@ -45,12 +31,7 @@ import accountApi, { Permission, Role } from "../app/AccountApi"; import CelebrationIcon from "@mui/icons-material/Celebration"; import UpgradeDialog from "./UpgradeDialog"; import { AccountContext } from "./App"; -import { - PermissionDenyAll, - PermissionRead, - PermissionReadWrite, - PermissionWrite, -} from "./ReserveIcons"; +import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; import IconButton from "@mui/material/IconButton"; import { SubscriptionPopup } from "./SubscriptionPopup"; @@ -59,11 +40,7 @@ const navWidth = 280; const Navigation = (props) => { const navigationList = ; return ( - + {/* Mobile drawer; only shown if menu icon clicked (mobile open) and display is small */} { }; const handleSubscribeSubmit = (subscription) => { - console.log( - `[Navigation] New subscription: ${subscription.id}`, - subscription - ); + console.log(`[Navigation] New subscription: ${subscription.id}`, subscription); handleSubscribeReset(); navigate(routes.forSubscription(subscription)); handleRequestNotificationPermission(); }; const handleRequestNotificationPermission = () => { - notifier.maybeRequestPermission((granted) => - props.onNotificationGranted(granted) - ); + notifier.maybeRequestPermission((granted) => props.onNotificationGranted(granted)); }; const handleAccountClick = () => { @@ -134,39 +106,19 @@ const NavList = (props) => { const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid; const showSubscriptionsList = props.subscriptions?.length > 0; const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); - const showNotificationContextNotSupportedBox = - notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser - const showNotificationGrantBox = - notifier.supported() && - props.subscriptions?.length > 0 && - !props.notificationsGranted; - const navListPadding = - showNotificationGrantBox || - showNotificationBrowserNotSupportedBox || - showNotificationContextNotSupportedBox - ? "0" - : ""; + const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser + const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted; + const navListPadding = showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox ? "0" : ""; return ( <> - {showNotificationBrowserNotSupportedBox && ( - - )} - {showNotificationContextNotSupportedBox && ( - - )} - {showNotificationGrantBox && ( - - )} + {showNotificationBrowserNotSupportedBox && } + {showNotificationContextNotSupportedBox && } + {showNotificationGrantBox && } {!showSubscriptionsList && ( - navigate(routes.app)} - selected={location.pathname === config.app_root} - > + navigate(routes.app)} selected={location.pathname === config.app_root}> @@ -176,37 +128,25 @@ const NavList = (props) => { {showSubscriptionsList && ( <> {t("nav_topics_title")} - navigate(routes.app)} - selected={location.pathname === config.app_root} - > + navigate(routes.app)} selected={location.pathname === config.app_root}> - + )} {session.exists() && ( - + )} - navigate(routes.settings)} - selected={location.pathname === routes.settings} - > + navigate(routes.settings)} selected={location.pathname === routes.settings}> @@ -260,8 +200,7 @@ const UpgradeBanner = () => { width: `${Navigation.width - 1}px`, bottom: 0, mt: "auto", - background: - "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)", + background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)", }} > @@ -277,8 +216,7 @@ const UpgradeBanner = () => { style: { fontWeight: 500, fontSize: "1.1rem", - background: - "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)", + background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", }, @@ -290,11 +228,7 @@ const UpgradeBanner = () => { }} /> - setDialogOpen(false)} - /> + setDialogOpen(false)} /> ); }; @@ -303,9 +237,7 @@ const SubscriptionList = (props) => { const sortedSubscriptions = props.subscriptions .filter((s) => !s.internal) .sort((a, b) => { - return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) - ? -1 - : 1; + return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1; }); return ( <> @@ -313,10 +245,7 @@ const SubscriptionList = (props) => { ))} @@ -331,19 +260,12 @@ const SubscriptionItem = (props) => { const subscription = props.subscription; const iconBadge = subscription.new <= 99 ? subscription.new : "99+"; const displayName = topicDisplayName(subscription); - const ariaLabel = - subscription.state === ConnectionState.Connecting - ? `${displayName} (${t("nav_button_connecting")})` - : displayName; + const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName; const icon = subscription.state === ConnectionState.Connecting ? ( ) : ( - + ); @@ -355,12 +277,7 @@ const SubscriptionItem = (props) => { return ( <> - + {icon} { {subscription.reservation?.everyone && ( {subscription.reservation?.everyone === Permission.READ_WRITE && ( - + )} @@ -383,9 +298,7 @@ const SubscriptionItem = (props) => { )} {subscription.reservation?.everyone === Permission.WRITE_ONLY && ( - + )} @@ -397,11 +310,7 @@ const SubscriptionItem = (props) => { )} {subscription.mutedUntil > 0 && ( - + @@ -421,11 +330,7 @@ const SubscriptionItem = (props) => { - setMenuAnchorEl(null)} - /> + setMenuAnchorEl(null)} /> ); @@ -438,12 +343,7 @@ const NotificationGrantAlert = (props) => { {t("alert_grant_title")} {t("alert_grant_description")} - @@ -458,9 +358,7 @@ const NotificationBrowserNotSupportedAlert = () => { <> {t("alert_not_supported_title")} - - {t("alert_not_supported_description")} - + {t("alert_not_supported_description")} @@ -477,13 +375,7 @@ const NotificationContextNotSupportedAlert = () => { - ), + mdnLink: , }} /> diff --git a/web/src/components/Notifications.js b/web/src/components/Notifications.js index e55674b..ad44873 100644 --- a/web/src/components/Notifications.js +++ b/web/src/components/Notifications.js @@ -1,16 +1,5 @@ import Container from "@mui/material/Container"; -import { - ButtonBase, - CardActions, - CardContent, - CircularProgress, - Fade, - Link, - Modal, - Snackbar, - Stack, - Tooltip, -} from "@mui/material"; +import { ButtonBase, CardActions, CardContent, CircularProgress, Fade, Link, Modal, Snackbar, Stack, Tooltip } from "@mui/material"; import Card from "@mui/material/Card"; import Typography from "@mui/material/Typography"; import * as React from "react"; @@ -29,11 +18,7 @@ import { import IconButton from "@mui/material/IconButton"; import CheckIcon from "@mui/icons-material/Check"; import CloseIcon from "@mui/icons-material/Close"; -import { - LightboxBackdrop, - Paragraph, - VerticallyCenteredContainer, -} from "./styles"; +import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles"; import { useLiveQuery } from "dexie-react-hooks"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; @@ -68,10 +53,7 @@ export const SingleSubscription = () => { const AllSubscriptionsList = (props) => { const subscriptions = props.subscriptions; - const notifications = useLiveQuery( - () => subscriptionManager.getAllNotifications(), - [] - ); + const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []); if (notifications === null || notifications === undefined) { return ; } else if (subscriptions.length === 0) { @@ -79,33 +61,18 @@ const AllSubscriptionsList = (props) => { } else if (notifications.length === 0) { return ; } - return ( - - ); + return ; }; const SingleSubscriptionList = (props) => { const subscription = props.subscription; - const notifications = useLiveQuery( - () => subscriptionManager.getNotifications(subscription.id), - [subscription] - ); + const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]); if (notifications === null || notifications === undefined) { return ; } else if (notifications.length === 0) { return ; } - return ( - - ); + return ; }; const NotificationList = (props) => { @@ -146,18 +113,9 @@ const NotificationList = (props) => { > {notifications.slice(0, count).map((notification) => ( - setSnackOpen(true)} - /> + setSnackOpen(true)} /> ))} - setSnackOpen(false)} - message={t("notifications_copied_to_clipboard")} - /> + setSnackOpen(false)} message={t("notifications_copied_to_clipboard")} /> @@ -176,45 +134,29 @@ const NotificationItem = (props) => { await subscriptionManager.deleteNotification(notification.id); }; const handleMarkRead = async () => { - console.log( - `[Notifications] Marking notification ${notification.id} as read` - ); + console.log(`[Notifications] Marking notification ${notification.id} as read`); await subscriptionManager.markNotificationRead(notification.id); }; const handleCopy = (s) => { navigator.clipboard.writeText(s); props.onShowSnack(); }; - const expired = - attachment && attachment.expires && attachment.expires < Date.now() / 1000; + const expired = attachment && attachment.expires && attachment.expires < Date.now() / 1000; const hasAttachmentActions = attachment && !expired; const hasClickAction = notification.click; - const hasUserActions = - notification.actions && notification.actions.length > 0; + const hasUserActions = notification.actions && notification.actions.length > 0; const showActions = hasAttachmentActions || hasClickAction || hasUserActions; return ( - + - + {notification.new === 1 && ( - + @@ -247,9 +189,7 @@ const NotificationItem = (props) => { )} - {autolink( - maybeAppendActionErrors(formatMessage(notification), notification) - )} + {autolink(maybeAppendActionErrors(formatMessage(notification), notification))} {attachment && } {tags && ( @@ -263,36 +203,28 @@ const NotificationItem = (props) => { {hasAttachmentActions && ( <> - + - + )} {hasClickAction && ( <> - + - + )} @@ -311,18 +243,10 @@ const NotificationItem = (props) => { * [2] https://github.com/bryanwoods/autolink-js/blob/master/autolink.js#L9 */ const autolink = (s) => { - const parts = s.split( - /(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi - ); + const parts = s.split(/(\bhttps?:\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|]\b)/gi); for (let i = 1; i < parts.length; i += 2) { parts[i] = ( - + {shortUrl(parts[i])} ); @@ -342,8 +266,7 @@ const Attachment = (props) => { const attachment = props.attachment; const expired = attachment.expires && attachment.expires < Date.now() / 1000; const expires = attachment.expires && attachment.expires > Date.now() / 1000; - const displayableImage = - !expired && attachment.type && attachment.type.startsWith("image/"); + const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/"); // Unexpired image if (displayableImage) { @@ -386,10 +309,7 @@ const Attachment = (props) => { }} > - + {attachment.name} {maybeInfoText} @@ -420,10 +340,7 @@ const Attachment = (props) => { }} > - + {attachment.name} {maybeInfoText} @@ -453,11 +370,7 @@ const Image = (props) => { cursor: "pointer", }} /> - setOpen(false)} - BackdropComponent={LightboxBackdrop} - > + setOpen(false)} BackdropComponent={LightboxBackdrop}> { return ( <> {props.notification.actions.map((action) => ( - + ))} ); @@ -502,10 +411,7 @@ const UserAction = (props) => { return ( - @@ -513,9 +419,7 @@ const UserAction = (props) => { ); } else if (action.action === "view") { return ( - + - + ); @@ -350,9 +277,7 @@ const UserTable = (props) => { setDialogOpen(false); try { await userManager.save(user); - console.debug( - `[Preferences] User ${user.username} for ${user.baseUrl} updated` - ); + console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} updated`); } catch (e) { console.log(`[Preferences] Error updating user.`, e); } @@ -361,9 +286,7 @@ const UserTable = (props) => { const handleDeleteClick = async (user) => { try { await userManager.delete(user.baseUrl); - console.debug( - `[Preferences] User ${user.username} for ${user.baseUrl} deleted` - ); + console.debug(`[Preferences] User ${user.username} for ${user.baseUrl} deleted`); } catch (e) { console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e); } @@ -373,43 +296,25 @@ const UserTable = (props) => { - - {t("prefs_users_table_user_header")} - + {t("prefs_users_table_user_header")} {t("prefs_users_table_base_url_header")} {props.users?.map((user) => ( - - + + {user.username} - - {user.baseUrl} - + {user.baseUrl} {(!session.exists() || user.baseUrl !== config.base_url) && ( <> - handleEditClick(user)} - aria-label={t("prefs_users_edit_button")} - > + handleEditClick(user)} aria-label={t("prefs_users_edit_button")}> - handleDeleteClick(user)} - aria-label={t("prefs_users_delete_button")} - > + handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}> @@ -454,15 +359,8 @@ const UserDialog = (props) => { return username.length > 0 && password.length > 0; } const baseUrlValid = validUrl(baseUrl); - const baseUrlExists = props.users - ?.map((user) => user.baseUrl) - .includes(baseUrl); - return ( - baseUrlValid && - !baseUrlExists && - username.length > 0 && - password.length > 0 - ); + const baseUrlExists = props.users?.map((user) => user.baseUrl).includes(baseUrl); + return baseUrlValid && !baseUrlExists && username.length > 0 && password.length > 0; })(); const handleSubmit = async () => { props.onSubmit({ @@ -480,11 +378,7 @@ const UserDialog = (props) => { }, [editMode, props.user]); return ( - - {editMode - ? t("prefs_users_dialog_title_edit") - : t("prefs_users_dialog_title_add")} - + {editMode ? t("prefs_users_dialog_title_edit") : t("prefs_users_dialog_title_add")} {!editMode && ( { // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts. // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows. - const randomFlags = shuffle([ - "🇬🇧", - "🇺🇸", - "🇪🇸", - "🇫🇷", - "🇧🇬", - "🇨🇿", - "🇩🇪", - "🇵🇱", - "🇺🇦", - "🇨🇳", - "🇮🇹", - "🇭🇺", - "🇧🇷", - "🇳🇱", - "🇮🇩", - "🇯🇵", - "🇷🇺", - "🇹🇷", - ]).slice(0, 3); + const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇵🇱", "🇺🇦", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3); const showFlags = !navigator.userAgent.includes("Windows"); let title = t("prefs_appearance_language_title"); if (showFlags) { @@ -635,8 +510,7 @@ const Reservations = () => { return <>; } const reservations = account.reservations || []; - const limitReached = - account.role === Role.USER && account.stats.reservations_remaining === 0; + const limitReached = account.role === Role.USER && account.stats.reservations_remaining === 0; const handleAddClick = () => { setDialogKey((prev) => prev + 1); @@ -650,23 +524,14 @@ const Reservations = () => { {t("prefs_reservations_title")} {t("prefs_reservations_description")} - {reservations.length > 0 && ( - - )} - {limitReached && ( - {t("prefs_reservations_limit_reached")} - )} + {reservations.length > 0 && } + {limitReached && {t("prefs_reservations_limit_reached")}} - setDialogOpen(false)} - /> + setDialogOpen(false)} /> ); @@ -680,14 +545,7 @@ const ReservationsTable = (props) => { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { subscriptions } = useOutletContext(); const localSubscriptions = - subscriptions?.length > 0 - ? Object.assign( - {}, - ...subscriptions - .filter((s) => s.baseUrl === config.base_url) - .map((s) => ({ [s.topic]: s })) - ) - : {}; + subscriptions?.length > 0 ? Object.assign({}, ...subscriptions.filter((s) => s.baseUrl === config.base_url).map((s) => ({ [s.topic]: s }))) : {}; const handleEditClick = (reservation) => { setDialogKey((prev) => prev + 1); @@ -709,70 +567,46 @@ const ReservationsTable = (props) => {
- - {t("prefs_reservations_table_topic_header")} - + {t("prefs_reservations_table_topic_header")} {t("prefs_reservations_table_access_header")} {props.reservations.map((reservation) => ( - - + + {reservation.topic} {reservation.everyone === Permission.READ_WRITE && ( <> - + {t("prefs_reservations_table_everyone_read_write")} )} {reservation.everyone === Permission.READ_ONLY && ( <> - + {t("prefs_reservations_table_everyone_read_only")} )} {reservation.everyone === Permission.WRITE_ONLY && ( <> - + {t("prefs_reservations_table_everyone_write_only")} )} {reservation.everyone === Permission.DENY_ALL && ( <> - + {t("prefs_reservations_table_everyone_deny_all")} )} {!localSubscriptions[reservation.topic] && ( - + } onClick={() => handleSubscribeClick(reservation)} @@ -782,16 +616,10 @@ const ReservationsTable = (props) => { /> )} - handleEditClick(reservation)} - aria-label={t("prefs_reservations_edit_button")} - > + handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> - handleDeleteClick(reservation)} - aria-label={t("prefs_reservations_delete_button")} - > + handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}> diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index e8825de..7d20103 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -1,17 +1,7 @@ import * as React from "react"; import { useContext, useEffect, useRef, useState } from "react"; import theme from "./theme"; -import { - Checkbox, - Chip, - FormControl, - FormControlLabel, - InputLabel, - Link, - Select, - Tooltip, - useMediaQuery, -} from "@mui/material"; +import { Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, Tooltip, useMediaQuery } from "@mui/material"; import TextField from "@mui/material/TextField"; import priority1 from "../img/priority-1.svg"; import priority2 from "../img/priority-2.svg"; @@ -27,14 +17,7 @@ import IconButton from "@mui/material/IconButton"; import InsertEmoticonIcon from "@mui/icons-material/InsertEmoticon"; import { Close } from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; -import { - formatBytes, - maybeWithAuth, - topicShortUrl, - topicUrl, - validTopic, - validUrl, -} from "../app/utils"; +import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils"; import Box from "@mui/material/Box"; import AttachmentIcon from "./AttachmentIcon"; import DialogFooter from "./DialogFooter"; @@ -152,10 +135,7 @@ const PublishDialog = (props) => { url.searchParams.append("delay", delay.trim()); } if (attachFile && message.trim()) { - url.searchParams.append( - "message", - message.replaceAll("\n", "\\n").trim() - ); + url.searchParams.append("message", message.replaceAll("\n", "\\n").trim()); } const body = attachFile ? attachFile : message; try { @@ -184,11 +164,7 @@ const PublishDialog = (props) => { setActiveRequest(null); } } catch (e) { - setStatus( - - {e} - - ); + setStatus({e}); setActiveRequest(null); } }; @@ -198,8 +174,7 @@ const PublishDialog = (props) => { const account = await accountApi.get(); const fileSizeLimit = account.limits.attachment_file_size ?? 0; const remainingBytes = account.stats.attachment_total_size_remaining; - const fileSizeLimitReached = - fileSizeLimit > 0 && file.size > fileSizeLimit; + const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit; const quotaReached = remainingBytes > 0 && file.size > remainingBytes; if (fileSizeLimitReached && quotaReached) { return setAttachFileError( @@ -282,18 +257,8 @@ const PublishDialog = (props) => { return ( <> - {dropZone && ( - - )} - + {dropZone && } + {baseUrl && topic ? t("publish_dialog_title_topic", { @@ -377,16 +342,8 @@ const PublishDialog = (props) => { }} />
- - + + { "aria-label": t("publish_dialog_tags_label"), }} /> - + + {showAttachFile && ( { /> )} {account && !account?.phone_numbers && ( - + { - ), + docsLink: , }} /> - {activeRequest && ( - - )} + {activeRequest && } {!activeRequest && ( <> { checked={publishAnother} onChange={(ev) => setPublishAnother(ev.target.checked)} inputProps={{ - "aria-label": t( - "publish_dialog_checkbox_publish_another" - ), + "aria-label": t("publish_dialog_checkbox_publish_another"), }} /> } /> - + @@ -796,12 +721,7 @@ const ClosableRow = (props) => { {props.children} {closable && ( - + )} @@ -856,23 +776,14 @@ const AttachmentBox = (props) => { {formatBytes(file.size)} {props.error && ( - + {" "} ({props.error}) )} - + @@ -888,22 +799,14 @@ const ExpandingTextField = (props) => { if (!boundingRect) { return props.minWidth; } - return boundingRect.width >= props.minWidth - ? Math.round(boundingRect.width) - : props.minWidth; + return boundingRect.width >= props.minWidth ? Math.round(boundingRect.width) : props.minWidth; }; useEffect(() => { setTextWidth(determineTextWidth() + 5); }, [props.value]); return ( <> - + {props.value} { alignItems: "center", }} > - - {t("publish_dialog_drop_file_here")} - + {t("publish_dialog_drop_file_here")} ); diff --git a/web/src/components/ReserveDialogs.js b/web/src/components/ReserveDialogs.js index f36ea6c..e606c0d 100644 --- a/web/src/components/ReserveDialogs.js +++ b/web/src/components/ReserveDialogs.js @@ -28,16 +28,13 @@ export const ReserveAddDialog = (props) => { const [everyone, setEveryone] = useState(Permission.DENY_ALL); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const allowTopicEdit = !props.topic; - const alreadyReserved = - props.reservations.filter((r) => r.topic === topic).length > 0; + const alreadyReserved = props.reservations.filter((r) => r.topic === topic).length > 0; const submitButtonEnabled = validTopic(topic) && !alreadyReserved; const handleSubmit = async () => { try { await accountApi.upsertReservation(topic, everyone); - console.debug( - `[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}` - ); + console.debug(`[ReserveAddDialog] Added reservation for topic ${topic}: ${everyone}`); } catch (e) { console.log(`[ReserveAddDialog] Error adding topic reservation.`, e); if (e instanceof UnauthorizedError) { @@ -54,18 +51,10 @@ export const ReserveAddDialog = (props) => { }; return ( - + {t("prefs_reservations_dialog_title_add")} - - {t("prefs_reservations_dialog_description")} - + {t("prefs_reservations_dialog_description")} {allowTopicEdit && ( { variant="standard" /> )} - + @@ -99,17 +84,13 @@ export const ReserveAddDialog = (props) => { export const ReserveEditDialog = (props) => { const { t } = useTranslation(); const [error, setError] = useState(""); - const [everyone, setEveryone] = useState( - props.reservation?.everyone || Permission.DENY_ALL - ); + const [everyone, setEveryone] = useState(props.reservation?.everyone || Permission.DENY_ALL); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const handleSubmit = async () => { try { await accountApi.upsertReservation(props.reservation.topic, everyone); - console.debug( - `[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}` - ); + console.debug(`[ReserveEditDialog] Updated reservation for topic ${t}: ${everyone}`); } catch (e) { console.log(`[ReserveEditDialog] Error updating topic reservation.`, e); if (e instanceof UnauthorizedError) { @@ -123,23 +104,11 @@ export const ReserveEditDialog = (props) => { }; return ( - + {t("prefs_reservations_dialog_title_edit")} - - {t("prefs_reservations_dialog_description")} - - + {t("prefs_reservations_dialog_description")} + @@ -158,9 +127,7 @@ export const ReserveDeleteDialog = (props) => { const handleSubmit = async () => { try { await accountApi.deleteReservation(props.topic, deleteMessages); - console.debug( - `[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}` - ); + console.debug(`[ReserveDeleteDialog] Deleted reservation for topic ${props.topic}`); } catch (e) { console.log(`[ReserveDeleteDialog] Error deleting topic reservation.`, e); if (e instanceof UnauthorizedError) { @@ -174,18 +141,10 @@ export const ReserveDeleteDialog = (props) => { }; return ( - + {t("prefs_reservations_dialog_title_delete")} - - {t("reservation_delete_dialog_description")} - + {t("reservation_delete_dialog_description")} diff --git a/web/src/components/ReserveTopicSelect.js b/web/src/components/ReserveTopicSelect.js index 76113ba..83ac069 100644 --- a/web/src/components/ReserveTopicSelect.js +++ b/web/src/components/ReserveTopicSelect.js @@ -4,12 +4,7 @@ import { useTranslation } from "react-i18next"; import MenuItem from "@mui/material/MenuItem"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; -import { - PermissionDenyAll, - PermissionRead, - PermissionReadWrite, - PermissionWrite, -} from "./ReserveIcons"; +import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; import { Permission } from "../app/AccountApi"; const ReserveTopicSelect = (props) => { @@ -34,33 +29,25 @@ const ReserveTopicSelect = (props) => { - + - + - + - + diff --git a/web/src/components/Signup.js b/web/src/components/Signup.js index 39409a5..37a3928 100644 --- a/web/src/components/Signup.js +++ b/web/src/components/Signup.js @@ -31,9 +31,7 @@ const Signup = () => { try { await accountApi.create(user.username, user.password); const token = await accountApi.login(user); - console.log( - `[Signup] User signup for user ${user.username} successful, token is ${token}` - ); + console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); session.store(user.username, token); window.location.href = routes.app; } catch (e) { @@ -51,9 +49,7 @@ const Signup = () => { if (!config.enable_signup) { return ( - - {t("signup_disabled")} - + {t("signup_disabled")} ); } @@ -61,12 +57,7 @@ const Signup = () => { return ( {t("signup_title")} - + { ), }} /> - {error && ( diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 940eafe..7be69a6 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -6,21 +6,10 @@ import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; -import { - Autocomplete, - Checkbox, - FormControlLabel, - FormGroup, - useMediaQuery, -} from "@mui/material"; +import { Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery } from "@mui/material"; import theme from "./theme"; import api from "../app/Api"; -import { - randomAlphanumericString, - topicUrl, - validTopic, - validUrl, -} from "../app/utils"; +import { randomAlphanumericString, topicUrl, validTopic, validUrl } from "../app/utils"; import userManager from "../app/UserManager"; import subscriptionManager from "../app/SubscriptionManager"; import poller from "../app/Poller"; @@ -64,14 +53,7 @@ const SubscribeDialog = (props) => { onSuccess={handleSuccess} /> )} - {showLoginPage && ( - setShowLoginPage(false)} - onSuccess={handleSuccess} - /> - )} + {showLoginPage && setShowLoginPage(false)} onSuccess={handleSuccess} />}
); }; @@ -85,37 +67,20 @@ const SubscribePage = (props) => { const [everyone, setEveryone] = useState(Permission.DENY_ALL); const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url; const topic = props.topic; - const existingTopicUrls = props.subscriptions.map((s) => - topicUrl(s.baseUrl, s.topic) - ); - const existingBaseUrls = Array.from( - new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)]) - ).filter((s) => s !== config.base_url); - const showReserveTopicCheckbox = - config.enable_reservations && - !anotherServerVisible && - (config.enable_payments || account); + const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.topic)); + const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter((s) => s !== config.base_url); + const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account); const reserveTopicEnabled = - session.exists() && - (account?.role === Role.ADMIN || - (account?.role === Role.USER && - (account?.stats.reservations_remaining || 0) > 0)); + session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0)); const handleSubscribe = async () => { const user = await userManager.get(baseUrl); // May be undefined - const username = user - ? user.username - : t("subscribe_dialog_error_user_anonymous"); + const username = user ? user.username : t("subscribe_dialog_error_user_anonymous"); // Check read access to topic const success = await api.topicAuth(baseUrl, topic, user); if (!success) { - console.log( - `[SubscribeDialog] Login to ${topicUrl( - baseUrl, - topic - )} failed for user ${username}` - ); + console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); if (user) { setError( t("subscribe_dialog_error_user_not_authorized", { @@ -130,14 +95,8 @@ const SubscribePage = (props) => { } // Reserve topic (if requested) - if ( - session.exists() && - baseUrl === config.base_url && - reserveTopicVisible - ) { - console.log( - `[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}` - ); + if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) { + console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`); try { await accountApi.upsertReservation(topic, everyone); } catch (e) { @@ -151,12 +110,7 @@ const SubscribePage = (props) => { } } - console.log( - `[SubscribeDialog] Successful login to ${topicUrl( - baseUrl, - topic - )} for user ${username}` - ); + console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); props.onSuccess(); }; @@ -167,14 +121,10 @@ const SubscribePage = (props) => { const subscribeButtonEnabled = (() => { if (anotherServerVisible) { - const isExistingTopicUrl = existingTopicUrls.includes( - topicUrl(baseUrl, topic) - ); + const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic)); return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl; } else { - const isExistingTopicUrl = existingTopicUrls.includes( - topicUrl(config.base_url, topic) - ); + const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic)); return validTopic(topic) && !isExistingTopicUrl; } })(); @@ -191,9 +141,7 @@ const SubscribePage = (props) => { <> {t("subscribe_dialog_subscribe_title")} - - {t("subscribe_dialog_subscribe_description")} - + {t("subscribe_dialog_subscribe_description")}
{ } /> - {reserveTopicVisible && ( - - )} + {reserveTopicVisible && } )} {!reserveTopicVisible && ( @@ -253,9 +199,7 @@ const SubscribePage = (props) => { } @@ -268,12 +212,7 @@ const SubscribePage = (props) => { inputValue={props.baseUrl} onInputChange={updateBaseUrl} renderInput={(params) => ( - + )} /> )} @@ -281,9 +220,7 @@ const SubscribePage = (props) => { )} - + @@ -304,23 +241,11 @@ const LoginPage = (props) => { const user = { baseUrl, username, password }; const success = await api.topicAuth(baseUrl, topic, user); if (!success) { - console.log( - `[SubscribeDialog] Login to ${topicUrl( - baseUrl, - topic - )} failed for user ${username}` - ); - setError( - t("subscribe_dialog_error_user_not_authorized", { username: username }) - ); + console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`); + setError(t("subscribe_dialog_error_user_not_authorized", { username: username })); return; } - console.log( - `[SubscribeDialog] Successful login to ${topicUrl( - baseUrl, - topic - )} for user ${username}` - ); + console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`); await userManager.save(user); props.onSuccess(); }; @@ -329,9 +254,7 @@ const LoginPage = (props) => { <> {t("subscribe_dialog_login_title")} - - {t("subscribe_dialog_login_description")} - + {t("subscribe_dialog_login_description")} { - + ); diff --git a/web/src/components/SubscriptionPopup.js b/web/src/components/SubscriptionPopup.js index eb575dc..7452b8e 100644 --- a/web/src/components/SubscriptionPopup.js +++ b/web/src/components/SubscriptionPopup.js @@ -6,13 +6,7 @@ import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; -import { - Chip, - InputAdornment, - Portal, - Snackbar, - useMediaQuery, -} from "@mui/material"; +import { Chip, InputAdornment, Portal, Snackbar, useMediaQuery } from "@mui/material"; import theme from "./theme"; import subscriptionManager from "../app/SubscriptionManager"; import DialogFooter from "./DialogFooter"; @@ -28,11 +22,7 @@ import { useNavigate } from "react-router-dom"; import IconButton from "@mui/material/IconButton"; import { Clear } from "@mui/icons-material"; import { AccountContext } from "./App"; -import { - ReserveAddDialog, - ReserveDeleteDialog, - ReserveEditDialog, -} from "./ReserveDialogs"; +import { ReserveAddDialog, ReserveDeleteDialog, ReserveEditDialog } from "./ReserveDialogs"; import { UnauthorizedError } from "../app/errors"; export const SubscriptionPopup = (props) => { @@ -48,19 +38,11 @@ export const SubscriptionPopup = (props) => { const placement = props.placement ?? "left"; const reservations = account?.reservations || []; - const showReservationAdd = - config.enable_reservations && - !subscription?.reservation && - account?.stats.reservations_remaining > 0; + const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0; const showReservationAddDisabled = - !showReservationAdd && - config.enable_reservations && - !subscription?.reservation && - (config.enable_payments || account?.stats.reservations_remaining === 0); - const showReservationEdit = - config.enable_reservations && !!subscription?.reservation; - const showReservationDelete = - config.enable_reservations && !!subscription?.reservation; + !showReservationAdd && config.enable_reservations && !subscription?.reservation && (config.enable_payments || account?.stats.reservations_remaining === 0); + const showReservationEdit = config.enable_reservations && !!subscription?.reservation; + const showReservationDelete = config.enable_reservations && !!subscription?.reservation; const handleChangeDisplayName = async () => { setDisplayNameDialogOpen(true); @@ -115,14 +97,10 @@ export const SubscriptionPopup = (props) => { ])[0]; const nowSeconds = Math.round(Date.now() / 1000); const message = shuffle([ - `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime( - nowSeconds - )} right now. Is that early or late?`, + `Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(nowSeconds)} right now. Is that early or late?`, `So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`, `It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`, - `Alright then, it's ${formatShortDateTime( - nowSeconds - )} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, + `Alright then, it's ${formatShortDateTime(nowSeconds)} already. Boy oh boy, where did the time go? I hope you're alright, friend.`, `There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`, `I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/announcements.`, `It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`, @@ -140,24 +118,16 @@ export const SubscriptionPopup = (props) => { }; const handleClearAll = async () => { - console.log( - `[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}` - ); + console.log(`[SubscriptionPopup] Deleting all notifications from ${props.subscription.id}`); await subscriptionManager.deleteNotifications(props.subscription.id); }; const handleUnsubscribe = async () => { - console.log( - `[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, - props.subscription - ); + console.log(`[SubscriptionPopup] Unsubscribing from ${props.subscription.id}`, props.subscription); await subscriptionManager.remove(props.subscription.id); if (session.exists() && !subscription.internal) { try { - await accountApi.deleteSubscription( - props.subscription.baseUrl, - props.subscription.topic - ); + await accountApi.deleteSubscription(props.subscription.baseUrl, props.subscription.topic); } catch (e) { console.log(`[SubscriptionPopup] Error unsubscribing`, e); if (e instanceof UnauthorizedError) { @@ -175,67 +145,26 @@ export const SubscriptionPopup = (props) => { return ( <> - - - {t("action_bar_change_display_name")} - - {showReservationAdd && ( - - {t("action_bar_reservation_add")} - - )} + + {t("action_bar_change_display_name")} + {showReservationAdd && {t("action_bar_reservation_add")}} {showReservationAddDisabled && ( - - {t("action_bar_reservation_add")} - + {t("action_bar_reservation_add")} )} - {showReservationEdit && ( - - {t("action_bar_reservation_edit")} - - )} - {showReservationDelete && ( - - {t("action_bar_reservation_delete")} - - )} - - {t("action_bar_send_test_notification")} - - - {t("action_bar_clear_notifications")} - - - {t("action_bar_unsubscribe")} - + {showReservationEdit && {t("action_bar_reservation_edit")}} + {showReservationDelete && {t("action_bar_reservation_delete")}} + {t("action_bar_send_test_notification")} + {t("action_bar_clear_notifications")} + {t("action_bar_unsubscribe")} - setShowPublishError(false)} - message={t("message_bar_error_publishing")} - /> - setDisplayNameDialogOpen(false)} - /> + setShowPublishError(false)} message={t("message_bar_error_publishing")} /> + setDisplayNameDialogOpen(false)} /> {showReservationAdd && ( - setReserveAddDialogOpen(false)} - /> + setReserveAddDialogOpen(false)} /> )} {showReservationEdit && ( { /> )} {showReservationDelete && ( - setReserveDeleteDialogOpen(false)} - /> + setReserveDeleteDialogOpen(false)} /> )} @@ -261,28 +186,17 @@ const DisplayNameDialog = (props) => { const { t } = useTranslation(); const subscription = props.subscription; const [error, setError] = useState(""); - const [displayName, setDisplayName] = useState( - subscription.displayName ?? "" - ); + const [displayName, setDisplayName] = useState(subscription.displayName ?? ""); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); const handleSave = async () => { await subscriptionManager.setDisplayName(subscription.id, displayName); if (session.exists() && !subscription.internal) { try { - console.log( - `[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}` - ); - await accountApi.updateSubscription( - subscription.baseUrl, - subscription.topic, - { display_name: displayName } - ); + console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`); + await accountApi.updateSubscription(subscription.baseUrl, subscription.topic, { display_name: displayName }); } catch (e) { - console.log( - `[SubscriptionSettingsDialog] Error updating subscription`, - e - ); + console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e); if (e instanceof UnauthorizedError) { session.resetAndRedirect(routes.login); } else { @@ -295,18 +209,10 @@ const DisplayNameDialog = (props) => { }; return ( - + {t("display_name_dialog_title")} - - {t("display_name_dialog_description")} - + {t("display_name_dialog_description")} { export const ReserveLimitChip = () => { const { account } = useContext(AccountContext); - if ( - account?.role === Role.ADMIN || - account?.stats.reservations_remaining > 0 - ) { + if (account?.role === Role.ADMIN || account?.stats.reservations_remaining > 0) { return <>; } else if (config.enable_payments) { - return account?.limits.reservations > 0 ? ( - - ) : ( - - ); + return account?.limits.reservations > 0 ? : ; } else if (account) { return ; } diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js index 94b878c..5ebbd7b 100644 --- a/web/src/components/UpgradeDialog.js +++ b/web/src/components/UpgradeDialog.js @@ -3,16 +3,7 @@ import { useContext, useEffect, useState } from "react"; import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; -import { - Alert, - CardActionArea, - CardContent, - Chip, - Link, - ListItem, - Switch, - useMediaQuery, -} from "@mui/material"; +import { Alert, CardActionArea, CardContent, Chip, Link, ListItem, Switch, useMediaQuery } from "@mui/material"; import theme from "./theme"; import Button from "@mui/material/Button"; import accountApi, { SubscriptionInterval } from "../app/AccountApi"; @@ -21,12 +12,7 @@ import routes from "./routes"; import Card from "@mui/material/Card"; import Typography from "@mui/material/Typography"; import { AccountContext } from "./App"; -import { - formatBytes, - formatNumber, - formatPrice, - formatShortDate, -} from "../app/utils"; +import { formatBytes, formatNumber, formatPrice, formatShortDate } from "../app/utils"; import { Trans, useTranslation } from "react-i18next"; import List from "@mui/material/List"; import { Check, Close } from "@mui/icons-material"; @@ -43,9 +29,7 @@ const UpgradeDialog = (props) => { const { account } = useContext(AccountContext); // May be undefined! const [error, setError] = useState(""); const [tiers, setTiers] = useState(null); - const [interval, setInterval] = useState( - account?.billing?.interval || SubscriptionInterval.YEAR - ); + const [interval, setInterval] = useState(account?.billing?.interval || SubscriptionInterval.YEAR); const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined const [loading, setLoading] = useState(false); const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); @@ -61,9 +45,7 @@ const UpgradeDialog = (props) => { return <>; } - const tiersMap = Object.assign( - ...tiers.map((tier) => ({ [tier.code]: tier })) - ); + const tiersMap = Object.assign(...tiers.map((tier) => ({ [tier.code]: tier }))); const newTier = tiersMap[newTierCode]; // May be undefined const currentTier = account?.tier; // May be undefined const currentInterval = account?.billing?.interval; // May be undefined @@ -75,10 +57,7 @@ const UpgradeDialog = (props) => { submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup"); submitAction = Action.REDIRECT_SIGNUP; banner = null; - } else if ( - currentTierCode === newTierCode && - (currentInterval === undefined || currentInterval === interval) - ) { + } else if (currentTierCode === newTierCode && (currentInterval === undefined || currentInterval === interval)) { submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); submitAction = null; banner = currentTierCode ? Banner.PRORATION_INFO : null; @@ -99,10 +78,7 @@ const UpgradeDialog = (props) => { // Exceptional conditions if (loading) { submitAction = null; - } else if ( - newTier?.code && - account?.reservations?.length > newTier?.limits?.reservations - ) { + } else if (newTier?.code && account?.reservations?.length > newTier?.limits?.reservations) { submitAction = null; banner = Banner.RESERVATIONS_WARNING; } @@ -115,10 +91,7 @@ const UpgradeDialog = (props) => { try { setLoading(true); if (submitAction === Action.CREATE_SUBSCRIPTION) { - const response = await accountApi.createBillingSubscription( - newTierCode, - interval - ); + const response = await accountApi.createBillingSubscription(newTierCode, interval); window.location.href = response.redirect_url; } else if (submitAction === Action.UPDATE_SUBSCRIPTION) { await accountApi.updateBillingSubscription(newTierCode, interval); @@ -142,16 +115,12 @@ const UpgradeDialog = (props) => { let discount = 0, upto = false; if (newTier?.prices) { - discount = Math.round( - ((newTier.prices.month * 12) / newTier.prices.year - 1) * 100 - ); + discount = Math.round(((newTier.prices.month * 12) / newTier.prices.year - 1) * 100); } else { let n = 0; for (const t of tiers) { if (t.prices) { - const tierDiscount = Math.round( - ((t.prices.month * 12) / t.prices.year - 1) * 100 - ); + const tierDiscount = Math.round(((t.prices.month * 12) / t.prices.year - 1) * 100); if (tierDiscount > discount) { discount = tierDiscount; n++; @@ -162,12 +131,7 @@ const UpgradeDialog = (props) => { } return ( - +
{t("account_upgrade_dialog_title")}
@@ -184,13 +148,7 @@ const UpgradeDialog = (props) => { - setInterval( - ev.target.checked - ? SubscriptionInterval.YEAR - : SubscriptionInterval.MONTH - ) - } + onChange={(ev) => setInterval(ev.target.checked ? SubscriptionInterval.YEAR : SubscriptionInterval.MONTH)} /> {t("account_upgrade_dialog_interval_yearly")} @@ -199,20 +157,12 @@ const UpgradeDialog = (props) => { )} @@ -258,9 +208,7 @@ const UpgradeDialog = (props) => { , }} @@ -309,9 +257,7 @@ const UpgradeDialog = (props) => { {error} - + @@ -382,16 +328,10 @@ const TierCard = (props) => { {tier.name || t("account_basics_tier_free")}
- + {formatPrice(monthlyPrice)} - {monthlyPrice > 0 && ( - <>/ {t("account_upgrade_dialog_tier_price_per_month")} - )} + {monthlyPrice > 0 && <>/ {t("account_upgrade_dialog_tier_price_per_month")}}
{tier.limits.reservations > 0 && ( @@ -423,21 +363,10 @@ const TierCard = (props) => { )} - {t( - "account_upgrade_dialog_tier_features_attachment_file_size", - { filesize: formatBytes(tier.limits.attachment_file_size, 0) } - )} + {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })} - {tier.limits.reservations === 0 && ( - - {t("account_upgrade_dialog_tier_features_no_reservations")} - - )} - {tier.limits.calls === 0 && ( - - {t("account_upgrade_dialog_tier_features_no_calls")} - - )} + {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} + {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}} {tier.prices && props.interval === SubscriptionInterval.MONTH && ( @@ -476,10 +405,7 @@ const FeatureItem = (props) => { {props.feature && } {!props.feature && } - {props.children}} - /> + {props.children}} /> ); }; diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 0fc0204..1835a4b 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -32,41 +32,25 @@ export const useConnectionListeners = (account, subscriptions, users) => { }; const handleInternalMessage = async (message) => { - console.log( - `[ConnectionListener] Received message on sync topic`, - message.message - ); + console.log(`[ConnectionListener] Received message on sync topic`, message.message); try { const data = JSON.parse(message.message); if (data.event === "sync") { console.log(`[ConnectionListener] Triggering account sync`); await accountApi.sync(); } else { - console.log( - `[ConnectionListener] Unknown message type. Doing nothing.` - ); + console.log(`[ConnectionListener] Unknown message type. Doing nothing.`); } } catch (e) { - console.log( - `[ConnectionListener] Error parsing sync topic message`, - e - ); + console.log(`[ConnectionListener] Error parsing sync topic message`, e); } }; const handleNotification = async (subscriptionId, notification) => { - const added = await subscriptionManager.addNotification( - subscriptionId, - notification - ); + const added = await subscriptionManager.addNotification(subscriptionId, notification); if (added) { - const defaultClickAction = (subscription) => - navigate(routes.forSubscription(subscription)); - await notifier.notify( - subscriptionId, - notification, - defaultClickAction - ); + const defaultClickAction = (subscription) => navigate(routes.forSubscription(subscription)); + await notifier.notify(subscriptionId, notification, defaultClickAction); } }; connectionManager.registerStateListener(subscriptionManager.updateState); @@ -109,20 +93,12 @@ export const useAutoSubscribe = (subscriptions, selected) => { return; } setHasRun(true); - const eligible = - params.topic && !selected && !disallowedTopic(params.topic); + const eligible = params.topic && !selected && !disallowedTopic(params.topic); if (eligible) { - const baseUrl = params.baseUrl - ? expandSecureUrl(params.baseUrl) - : config.base_url; - console.log( - `[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}` - ); + const baseUrl = params.baseUrl ? expandSecureUrl(params.baseUrl) : config.base_url; + console.log(`[Hooks] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`); (async () => { - const subscription = await subscriptionManager.add( - baseUrl, - params.topic - ); + const subscription = await subscriptionManager.add(baseUrl, params.topic); if (session.exists()) { try { await accountApi.addSubscription(baseUrl, params.topic); From c87549e71a10bc789eac8036078228f06e515a8e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 23 May 2023 20:16:29 -0400 Subject: [PATCH 56/97] Width, again --- web/.prettierignore | 1 + web/package.json | 2 +- web/public/index.html | 3 +- web/src/app/Api.js | 10 ++++- web/src/app/Connection.js | 4 +- web/src/app/ConnectionManager.js | 4 +- web/src/app/utils.js | 3 +- web/src/components/Account.js | 53 ++++++++++++++++++++----- web/src/components/ActionBar.js | 8 +++- web/src/components/EmojiPicker.js | 8 +++- web/src/components/ErrorBoundary.js | 4 +- web/src/components/Messaging.js | 11 ++++- web/src/components/Navigation.js | 3 +- web/src/components/Notifications.js | 13 +++++- web/src/components/Preferences.js | 41 +++++++++++++++++-- web/src/components/PublishDialog.js | 15 ++++++- web/src/components/Signup.js | 8 +++- web/src/components/SubscribeDialog.js | 11 ++++- web/src/components/SubscriptionPopup.js | 25 ++++++++++-- web/src/components/UpgradeDialog.js | 4 +- 20 files changed, 194 insertions(+), 37 deletions(-) diff --git a/web/.prettierignore b/web/.prettierignore index d50a46c..1465272 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -1,2 +1,3 @@ build/ +dist/ public/static/langs/ diff --git a/web/package.json b/web/package.json index 10c198d..727e790 100644 --- a/web/package.json +++ b/web/package.json @@ -45,6 +45,6 @@ ] }, "prettier": { - "printWidth": 160 + "printWidth": 140 } } diff --git a/web/public/index.html b/web/public/index.html index e59a62e..e8c7f8a 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -39,7 +39,8 @@
diff --git a/web/src/app/Api.js b/web/src/app/Api.js index 4d7ce82..b956e0b 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -1,4 +1,12 @@ -import { fetchLinesIterator, maybeWithAuth, topicShortUrl, topicUrl, topicUrlAuth, topicUrlJsonPoll, topicUrlJsonPollWithSince } from "./utils"; +import { + fetchLinesIterator, + maybeWithAuth, + topicShortUrl, + topicUrl, + topicUrlAuth, + topicUrlJsonPoll, + topicUrlJsonPollWithSince, +} from "./utils"; import userManager from "./UserManager"; import { fetchOrThrow } from "./errors"; diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 2341678..7b25467 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -57,7 +57,9 @@ class Connection { }; this.ws.onclose = (event) => { if (event.wasClean) { - console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`); + console.log( + `[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}` + ); this.ws = null; } else { const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length - 1)]; diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js index 15b94cd..f50ed53 100644 --- a/web/src/app/ConnectionManager.js +++ b/web/src/app/ConnectionManager.js @@ -74,7 +74,9 @@ class ConnectionManager { ); this.connections.set(connectionId, connection); console.log( - `[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})` + `[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${ + user ? user.username : "anonymous" + })` ); connection.start(); } diff --git a/web/src/app/utils.js b/web/src/app/utils.js index d6bb02d..88e3684 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -10,7 +10,8 @@ import config from "./config"; import { Base64 } from "js-base64"; export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`; -export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://"); +export const topicUrlWs = (baseUrl, topic) => + `${topicUrl(baseUrl, topic)}/ws`.replaceAll("https://", "wss://").replaceAll("http://", "ws://"); export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`; export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`; export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`; diff --git a/web/src/components/Account.js b/web/src/components/Account.js index b6710c6..5cb68c1 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -211,7 +211,10 @@ const ChangePasswordDialog = (props) => { - @@ -288,7 +291,13 @@ const AccountType = () => { )} {config.enable_payments && account.role === Role.USER && !account.billing?.subscription && ( - )} @@ -303,7 +312,11 @@ const AccountType = () => { )} {config.enable_payments && ( - setUpgradeDialogOpen(false)} /> + setUpgradeDialogOpen(false)} + /> )}
{account.billing?.status === SubscriptionStatus.PAST_DUE && ( @@ -574,7 +587,11 @@ const Stats = () => {
0 ? normalize(account.stats.reservations, account.limits.reservations) : 100} + value={ + account.role === Role.USER && account.limits.reservations > 0 + ? normalize(account.stats.reservations, account.limits.reservations) + : 100 + } /> )} @@ -602,7 +619,10 @@ const Stats = () => { : t("account_usage_unlimited")} - + {config.enable_emails && ( { : t("account_usage_unlimited")} - + )} {config.enable_calls && (account.role === Role.ADMIN || account.limits.calls > 0) && ( @@ -833,7 +856,12 @@ const TokensTable = (props) => { {tokens.map((token) => ( - + {token.token.slice(0, 12)} ... @@ -893,7 +921,12 @@ const TokensTable = (props) => { ))} - setSnackOpen(false)} message={t("account_tokens_table_copied_to_clipboard")} /> + setSnackOpen(false)} + message={t("account_tokens_table_copied_to_clipboard")} + /> @@ -958,7 +991,9 @@ const TokenDialog = (props) => {
- +
); diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index 2d44014..24aef72 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -98,7 +98,13 @@ const SettingsIcons = (props) => { {subscription.mutedUntil ? : } - setAnchorEl(ev.currentTarget)} aria-label={t("action_bar_toggle_action_menu")}> + setAnchorEl(ev.currentTarget)} + aria-label={t("action_bar_toggle_action_menu")} + > setAnchorEl(null)} /> diff --git a/web/src/components/EmojiPicker.js b/web/src/components/EmojiPicker.js index 3f9f4df..04cc5c7 100644 --- a/web/src/components/EmojiPicker.js +++ b/web/src/components/EmojiPicker.js @@ -99,7 +99,13 @@ const EmojiPicker = (props) => { }} > {Object.keys(emojisByCategory).map((category) => ( - + ))} diff --git a/web/src/components/ErrorBoundary.js b/web/src/components/ErrorBoundary.js index 2928218..21ee6a9 100644 --- a/web/src/components/ErrorBoundary.js +++ b/web/src/components/ErrorBoundary.js @@ -46,7 +46,9 @@ class ErrorBoundaryImpl extends React.Component { // Fetch additional info and a better stack trace StackTrace.fromError(error).then((stack) => { console.error("[ErrorBoundary] Stacktrace fetched", stack); - const niceStack = `${error.toString()}\n` + stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n"); + const niceStack = + `${error.toString()}\n` + + stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n"); this.setState({ niceStack }); }); } diff --git a/web/src/components/Messaging.js b/web/src/components/Messaging.js index 2fa7ed5..b6ed952 100644 --- a/web/src/components/Messaging.js +++ b/web/src/components/Messaging.js @@ -29,7 +29,9 @@ const Messaging = (props) => { return ( <> - {subscription && } + {subscription && ( + + )} { - setSnackOpen(false)} message={t("message_bar_error_publishing")} /> + setSnackOpen(false)} + message={t("message_bar_error_publishing")} + /> ); diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.js index 922d6fe..1eeb3e8 100644 --- a/web/src/components/Navigation.js +++ b/web/src/components/Navigation.js @@ -108,7 +108,8 @@ const NavList = (props) => { const showNotificationBrowserNotSupportedBox = !notifier.browserSupported(); const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted; - const navListPadding = showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox ? "0" : ""; + const navListPadding = + showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox ? "0" : ""; return ( <> diff --git a/web/src/components/Notifications.js b/web/src/components/Notifications.js index ad44873..35fd080 100644 --- a/web/src/components/Notifications.js +++ b/web/src/components/Notifications.js @@ -115,7 +115,12 @@ const NotificationList = (props) => { {notifications.slice(0, count).map((notification) => ( setSnackOpen(true)} /> ))} - setSnackOpen(false)} message={t("notifications_copied_to_clipboard")} /> + setSnackOpen(false)} + message={t("notifications_copied_to_clipboard")} + /> @@ -156,7 +161,11 @@ const NotificationItem = (props) => { {notification.new === 1 && ( - + diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index 3a27701..22252d9 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -251,7 +251,14 @@ const Users = () => { - + ); @@ -449,7 +456,26 @@ const Language = () => { // Country flags are displayed using emoji. Emoji rendering is handled by platform fonts. // Windows in particular does not yet play nicely with flag emoji so for now, hide flags on Windows. - const randomFlags = shuffle(["🇬🇧", "🇺🇸", "🇪🇸", "🇫🇷", "🇧🇬", "🇨🇿", "🇩🇪", "🇵🇱", "🇺🇦", "🇨🇳", "🇮🇹", "🇭🇺", "🇧🇷", "🇳🇱", "🇮🇩", "🇯🇵", "🇷🇺", "🇹🇷"]).slice(0, 3); + const randomFlags = shuffle([ + "🇬🇧", + "🇺🇸", + "🇪🇸", + "🇫🇷", + "🇧🇬", + "🇨🇿", + "🇩🇪", + "🇵🇱", + "🇺🇦", + "🇨🇳", + "🇮🇹", + "🇭🇺", + "🇧🇷", + "🇳🇱", + "🇮🇩", + "🇯🇵", + "🇷🇺", + "🇹🇷", + ]).slice(0, 3); const showFlags = !navigator.userAgent.includes("Windows"); let title = t("prefs_appearance_language_title"); if (showFlags) { @@ -531,7 +557,12 @@ const Reservations = () => { - setDialogOpen(false)} /> + setDialogOpen(false)} + /> ); @@ -545,7 +576,9 @@ const ReservationsTable = (props) => { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const { subscriptions } = useOutletContext(); const localSubscriptions = - subscriptions?.length > 0 ? Object.assign({}, ...subscriptions.filter((s) => s.baseUrl === config.base_url).map((s) => ({ [s.topic]: s }))) : {}; + subscriptions?.length > 0 + ? Object.assign({}, ...subscriptions.filter((s) => s.baseUrl === config.base_url).map((s) => ({ [s.topic]: s }))) + : {}; const handleEditClick = (reservation) => { setDialogKey((prev) => prev + 1); diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index 7d20103..240b0cc 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -783,7 +783,12 @@ const AttachmentBox = (props) => { )} - + @@ -806,7 +811,13 @@ const ExpandingTextField = (props) => { }, [props.value]); return ( <> - + {props.value} { ), }} /> - {error && ( diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 7be69a6..47a4ac7 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -68,7 +68,9 @@ const SubscribePage = (props) => { const baseUrl = anotherServerVisible ? props.baseUrl : config.base_url; const topic = props.topic; const existingTopicUrls = props.subscriptions.map((s) => topicUrl(s.baseUrl, s.topic)); - const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter((s) => s !== config.base_url); + const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map((s) => s.baseUrl)])).filter( + (s) => s !== config.base_url + ); const showReserveTopicCheckbox = config.enable_reservations && !anotherServerVisible && (config.enable_payments || account); const reserveTopicEnabled = session.exists() && (account?.role === Role.ADMIN || (account?.role === Role.USER && (account?.stats.reservations_remaining || 0) > 0)); @@ -212,7 +214,12 @@ const SubscribePage = (props) => { inputValue={props.baseUrl} onInputChange={updateBaseUrl} renderInput={(params) => ( - + )} /> )} diff --git a/web/src/components/SubscriptionPopup.js b/web/src/components/SubscriptionPopup.js index 7452b8e..2675db2 100644 --- a/web/src/components/SubscriptionPopup.js +++ b/web/src/components/SubscriptionPopup.js @@ -40,7 +40,10 @@ export const SubscriptionPopup = (props) => { const showReservationAdd = config.enable_reservations && !subscription?.reservation && account?.stats.reservations_remaining > 0; const showReservationAddDisabled = - !showReservationAdd && config.enable_reservations && !subscription?.reservation && (config.enable_payments || account?.stats.reservations_remaining === 0); + !showReservationAdd && + config.enable_reservations && + !subscription?.reservation && + (config.enable_payments || account?.stats.reservations_remaining === 0); const showReservationEdit = config.enable_reservations && !!subscription?.reservation; const showReservationDelete = config.enable_reservations && !!subscription?.reservation; @@ -161,10 +164,20 @@ export const SubscriptionPopup = (props) => { {t("action_bar_unsubscribe")} - setShowPublishError(false)} message={t("message_bar_error_publishing")} /> + setShowPublishError(false)} + message={t("message_bar_error_publishing")} + /> setDisplayNameDialogOpen(false)} /> {showReservationAdd && ( - setReserveAddDialogOpen(false)} /> + setReserveAddDialogOpen(false)} + /> )} {showReservationEdit && ( { /> )} {showReservationDelete && ( - setReserveDeleteDialogOpen(false)} /> + setReserveDeleteDialogOpen(false)} + /> )} diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js index 5ebbd7b..9ff991d 100644 --- a/web/src/components/UpgradeDialog.js +++ b/web/src/components/UpgradeDialog.js @@ -363,7 +363,9 @@ const TierCard = (props) => { )} - {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })} + {t("account_upgrade_dialog_tier_features_attachment_file_size", { + filesize: formatBytes(tier.limits.attachment_file_size, 0), + })} {tier.limits.reservations === 0 && {t("account_upgrade_dialog_tier_features_no_reservations")}} {tier.limits.calls === 0 && {t("account_upgrade_dialog_tier_features_no_calls")}} From a64e365add91e0f422bc27202b587e8e4407037e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 23 May 2023 20:18:03 -0400 Subject: [PATCH 57/97] Update .git-blame-ignore-revs --- .git-blame-ignore-revs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 1e07cd0..b700532 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,5 +1,7 @@ -# .git-blame-ignore-revs # https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view # Run prettier (https://github.com/binwiederhier/ntfy/pull/746) 6f6a2d1f693070bf72e89d86748080e4825c9164 +c87549e71a10bc789eac8036078228f06e515a8e +ca5d736a7169eb6b4b0d849e061d5bf9565dcc53 +2e27f58963feb9e4d1c573d4745d07770777fa7d From fa29da1a3266f10a6a7e343b9399362f4a122796 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 23 May 2023 20:19:17 -0400 Subject: [PATCH 58/97] Release notes --- docs/releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releases.md b/docs/releases.md index 49bb7fc..523f46f 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1226,3 +1226,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Support encoding any header as RFC 2047 ([#737](https://github.com/binwiederhier/ntfy/issues/737), thanks to [@cfouche3005](https://github.com/cfouche3005) for reporting) * Improved GitHub Actions flow ([#745](https://github.com/binwiederhier/ntfy/pull/745), thanks to [@nimbleghost](https://github.com/nimbleghost)) +* Web: Add JS formatter "prettier" ([#746](https://github.com/binwiederhier/ntfy/pull/746), thanks to [@nimbleghost](https://github.com/nimbleghost)) From d7eb1206fe3b4d78ea90e4d5d0fb3a3c35254ac8 Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Tue, 23 May 2023 21:20:20 +0200 Subject: [PATCH 59/97] Add eslint with eslint-config-airbnb --- Makefile | 5 ++- web/.eslintignore | 1 + web/.eslintrc | 31 +++++++++++++ web/.prettierignore | 1 + web/package-lock.json | 101 ++++++++++++++++++++++++++++++++++-------- web/package.json | 18 +++++--- 6 files changed, 132 insertions(+), 25 deletions(-) create mode 100644 web/.eslintignore create mode 100644 web/.eslintrc diff --git a/Makefile b/Makefile index 6786acb..cc571c1 100644 --- a/Makefile +++ b/Makefile @@ -145,6 +145,9 @@ web-format: web-format-check: cd web && npm run format:check +web-lint: + cd web && npm run lint + # Main server/client build cli: cli-deps @@ -233,7 +236,7 @@ cli-build-results: # Test/check targets -check: test web-format-check fmt-check vet lint staticcheck +check: test web-format-check fmt-check vet web-lint lint staticcheck test: .PHONY go test $(shell go list ./... | grep -vE 'ntfy/(test|examples|tools)') diff --git a/web/.eslintignore b/web/.eslintignore new file mode 100644 index 0000000..29c9584 --- /dev/null +++ b/web/.eslintignore @@ -0,0 +1 @@ +src/app/emojis.js \ No newline at end of file diff --git a/web/.eslintrc b/web/.eslintrc new file mode 100644 index 0000000..52e2c6b --- /dev/null +++ b/web/.eslintrc @@ -0,0 +1,31 @@ +{ + "extends": ["airbnb", "prettier"], + "env": { + "browser": true + }, + "globals": { + "config": "readonly" + }, + "parserOptions": { + "ecmaVersion": 2023 + }, + "rules": { + "no-console": "off", + "class-methods-use-this": "off", + "func-style": ["error", "expression"], + "no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"], + "no-await-in-loop": "error", + "import/no-cycle": "warn", + "react/prop-types": "off", + "react/destructuring-assignment": "off", + "react/jsx-no-useless-fragment": "off", + "react/jsx-props-no-spreading": "off", + "react/function-component-definition": [ + "error", + { + "namedComponents": "arrow-function", + "unnamedComponents": "arrow-function" + } + ] + } +} diff --git a/web/.prettierignore b/web/.prettierignore index 1465272..802cdb8 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -1,3 +1,4 @@ build/ dist/ public/static/langs/ +src/app/emojis.js diff --git a/web/package-lock.json b/web/package-lock.json index d830d63..1907a8d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -26,6 +26,13 @@ "stacktrace-js": "^2.0.2" }, "devDependencies": { + "eslint": "^8.41.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", "prettier": "^2.8.8", "react-scripts": "^5.0.0" } @@ -2531,9 +2538,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.40.0.tgz", - "integrity": "sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.41.0.tgz", + "integrity": "sha512-LxcyMGxwmTh2lY9FwHPGWOHmYFCZvbrFCBZL4FzSSsxsRPuhrYUg/49/0KDfW8tnIEaEHtfmn6+NPN+1DqaNmA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -7590,15 +7597,15 @@ } }, "node_modules/eslint": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.40.0.tgz", - "integrity": "sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.41.0.tgz", + "integrity": "sha512-WQDQpzGBOP5IrXPo4Hc0814r4/v2rrIsB0rhT7jtunIalgg6gYXWhRMOejVO8yH21T/FGaxjmFjBMNqcIlmH1Q==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", "@eslint/eslintrc": "^2.0.3", - "@eslint/js": "8.40.0", + "@eslint/js": "8.41.0", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -7618,13 +7625,12 @@ "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", - "grapheme-splitter": "^1.0.4", + "graphemer": "^1.4.0", "ignore": "^5.2.0", "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", @@ -7646,6 +7652,67 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-airbnb": { + "version": "19.0.4", + "resolved": "https://registry.npmjs.org/eslint-config-airbnb/-/eslint-config-airbnb-19.0.4.tgz", + "integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==", + "dev": true, + "dependencies": { + "eslint-config-airbnb-base": "^15.0.0", + "object.assign": "^4.1.2", + "object.entries": "^1.1.5" + }, + "engines": { + "node": "^10.12.0 || ^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^7.32.0 || ^8.2.0", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-react": "^7.28.0", + "eslint-plugin-react-hooks": "^4.3.0" + } + }, + "node_modules/eslint-config-airbnb-base": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz", + "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", + "dev": true, + "dependencies": { + "confusing-browser-globals": "^1.0.10", + "object.assign": "^4.1.2", + "object.entries": "^1.1.5", + "semver": "^6.3.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "peerDependencies": { + "eslint": "^7.32.0 || ^8.2.0", + "eslint-plugin-import": "^2.25.2" + } + }, + "node_modules/eslint-config-airbnb-base/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-config-prettier": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz", + "integrity": "sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-config-react-app": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", @@ -9210,6 +9277,12 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, "node_modules/gzip-size": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", @@ -12454,16 +12527,6 @@ "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.5.tgz", "integrity": "sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==" }, - "node_modules/js-sdsl": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.0.tgz", - "integrity": "sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==", - "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/web/package.json b/web/package.json index 727e790..be971bd 100644 --- a/web/package.json +++ b/web/package.json @@ -3,12 +3,13 @@ "version": "1.0.0", "private": true, "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject", + "start": "DISABLE_ESLINT_PLUGIN=true react-scripts start", + "build": "DISABLE_ESLINT_PLUGIN=true react-scripts build", + "test": "DISABLE_ESLINT_PLUGIN=true react-scripts test", + "eject": "DISABLE_ESLINT_PLUGIN=true react-scripts eject", "format": "prettier . --write", - "format:check": "prettier . --check" + "format:check": "prettier . --check", + "lint": "eslint --report-unused-disable-directives --ext .js,.jsx ./src/" }, "dependencies": { "@mui/icons-material": "^5.4.2", @@ -29,6 +30,13 @@ "stacktrace-js": "^2.0.2" }, "devDependencies": { + "eslint": "^8.41.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", "prettier": "^2.8.8", "react-scripts": "^5.0.0" }, From f558b4dbe9bb5b9e0e87fada1215de2558353173 Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Wed, 24 May 2023 09:02:33 +0200 Subject: [PATCH 60/97] Add `.jsx` filename extension (This is also required for Vite later) --- web/src/components/{Account.js => Account.jsx} | 0 web/src/components/{ActionBar.js => ActionBar.jsx} | 0 web/src/components/{App.js => App.jsx} | 0 web/src/components/{AttachmentIcon.js => AttachmentIcon.jsx} | 0 web/src/components/{AvatarBox.js => AvatarBox.jsx} | 0 web/src/components/{DialogFooter.js => DialogFooter.jsx} | 0 web/src/components/{EmojiPicker.js => EmojiPicker.jsx} | 0 web/src/components/{ErrorBoundary.js => ErrorBoundary.jsx} | 0 web/src/components/{Login.js => Login.jsx} | 0 web/src/components/{Messaging.js => Messaging.jsx} | 0 web/src/components/{Navigation.js => Navigation.jsx} | 0 web/src/components/{Notifications.js => Notifications.jsx} | 0 web/src/components/{PopupMenu.js => PopupMenu.jsx} | 0 web/src/components/{Pref.js => Pref.jsx} | 0 web/src/components/{Preferences.js => Preferences.jsx} | 0 web/src/components/{PublishDialog.js => PublishDialog.jsx} | 0 web/src/components/{ReserveDialogs.js => ReserveDialogs.jsx} | 0 web/src/components/{ReserveIcons.js => ReserveIcons.jsx} | 0 .../components/{ReserveTopicSelect.js => ReserveTopicSelect.jsx} | 0 web/src/components/{Signup.js => Signup.jsx} | 0 web/src/components/{SubscribeDialog.js => SubscribeDialog.jsx} | 0 .../components/{SubscriptionPopup.js => SubscriptionPopup.jsx} | 0 web/src/components/{UpgradeDialog.js => UpgradeDialog.jsx} | 0 web/src/components/{i18n.js => i18n.jsx} | 0 web/src/{index.js => index.jsx} | 0 25 files changed, 0 insertions(+), 0 deletions(-) rename web/src/components/{Account.js => Account.jsx} (100%) rename web/src/components/{ActionBar.js => ActionBar.jsx} (100%) rename web/src/components/{App.js => App.jsx} (100%) rename web/src/components/{AttachmentIcon.js => AttachmentIcon.jsx} (100%) rename web/src/components/{AvatarBox.js => AvatarBox.jsx} (100%) rename web/src/components/{DialogFooter.js => DialogFooter.jsx} (100%) rename web/src/components/{EmojiPicker.js => EmojiPicker.jsx} (100%) rename web/src/components/{ErrorBoundary.js => ErrorBoundary.jsx} (100%) rename web/src/components/{Login.js => Login.jsx} (100%) rename web/src/components/{Messaging.js => Messaging.jsx} (100%) rename web/src/components/{Navigation.js => Navigation.jsx} (100%) rename web/src/components/{Notifications.js => Notifications.jsx} (100%) rename web/src/components/{PopupMenu.js => PopupMenu.jsx} (100%) rename web/src/components/{Pref.js => Pref.jsx} (100%) rename web/src/components/{Preferences.js => Preferences.jsx} (100%) rename web/src/components/{PublishDialog.js => PublishDialog.jsx} (100%) rename web/src/components/{ReserveDialogs.js => ReserveDialogs.jsx} (100%) rename web/src/components/{ReserveIcons.js => ReserveIcons.jsx} (100%) rename web/src/components/{ReserveTopicSelect.js => ReserveTopicSelect.jsx} (100%) rename web/src/components/{Signup.js => Signup.jsx} (100%) rename web/src/components/{SubscribeDialog.js => SubscribeDialog.jsx} (100%) rename web/src/components/{SubscriptionPopup.js => SubscriptionPopup.jsx} (100%) rename web/src/components/{UpgradeDialog.js => UpgradeDialog.jsx} (100%) rename web/src/components/{i18n.js => i18n.jsx} (100%) rename web/src/{index.js => index.jsx} (100%) diff --git a/web/src/components/Account.js b/web/src/components/Account.jsx similarity index 100% rename from web/src/components/Account.js rename to web/src/components/Account.jsx diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.jsx similarity index 100% rename from web/src/components/ActionBar.js rename to web/src/components/ActionBar.jsx diff --git a/web/src/components/App.js b/web/src/components/App.jsx similarity index 100% rename from web/src/components/App.js rename to web/src/components/App.jsx diff --git a/web/src/components/AttachmentIcon.js b/web/src/components/AttachmentIcon.jsx similarity index 100% rename from web/src/components/AttachmentIcon.js rename to web/src/components/AttachmentIcon.jsx diff --git a/web/src/components/AvatarBox.js b/web/src/components/AvatarBox.jsx similarity index 100% rename from web/src/components/AvatarBox.js rename to web/src/components/AvatarBox.jsx diff --git a/web/src/components/DialogFooter.js b/web/src/components/DialogFooter.jsx similarity index 100% rename from web/src/components/DialogFooter.js rename to web/src/components/DialogFooter.jsx diff --git a/web/src/components/EmojiPicker.js b/web/src/components/EmojiPicker.jsx similarity index 100% rename from web/src/components/EmojiPicker.js rename to web/src/components/EmojiPicker.jsx diff --git a/web/src/components/ErrorBoundary.js b/web/src/components/ErrorBoundary.jsx similarity index 100% rename from web/src/components/ErrorBoundary.js rename to web/src/components/ErrorBoundary.jsx diff --git a/web/src/components/Login.js b/web/src/components/Login.jsx similarity index 100% rename from web/src/components/Login.js rename to web/src/components/Login.jsx diff --git a/web/src/components/Messaging.js b/web/src/components/Messaging.jsx similarity index 100% rename from web/src/components/Messaging.js rename to web/src/components/Messaging.jsx diff --git a/web/src/components/Navigation.js b/web/src/components/Navigation.jsx similarity index 100% rename from web/src/components/Navigation.js rename to web/src/components/Navigation.jsx diff --git a/web/src/components/Notifications.js b/web/src/components/Notifications.jsx similarity index 100% rename from web/src/components/Notifications.js rename to web/src/components/Notifications.jsx diff --git a/web/src/components/PopupMenu.js b/web/src/components/PopupMenu.jsx similarity index 100% rename from web/src/components/PopupMenu.js rename to web/src/components/PopupMenu.jsx diff --git a/web/src/components/Pref.js b/web/src/components/Pref.jsx similarity index 100% rename from web/src/components/Pref.js rename to web/src/components/Pref.jsx diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.jsx similarity index 100% rename from web/src/components/Preferences.js rename to web/src/components/Preferences.jsx diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.jsx similarity index 100% rename from web/src/components/PublishDialog.js rename to web/src/components/PublishDialog.jsx diff --git a/web/src/components/ReserveDialogs.js b/web/src/components/ReserveDialogs.jsx similarity index 100% rename from web/src/components/ReserveDialogs.js rename to web/src/components/ReserveDialogs.jsx diff --git a/web/src/components/ReserveIcons.js b/web/src/components/ReserveIcons.jsx similarity index 100% rename from web/src/components/ReserveIcons.js rename to web/src/components/ReserveIcons.jsx diff --git a/web/src/components/ReserveTopicSelect.js b/web/src/components/ReserveTopicSelect.jsx similarity index 100% rename from web/src/components/ReserveTopicSelect.js rename to web/src/components/ReserveTopicSelect.jsx diff --git a/web/src/components/Signup.js b/web/src/components/Signup.jsx similarity index 100% rename from web/src/components/Signup.js rename to web/src/components/Signup.jsx diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.jsx similarity index 100% rename from web/src/components/SubscribeDialog.js rename to web/src/components/SubscribeDialog.jsx diff --git a/web/src/components/SubscriptionPopup.js b/web/src/components/SubscriptionPopup.jsx similarity index 100% rename from web/src/components/SubscriptionPopup.js rename to web/src/components/SubscriptionPopup.jsx diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.jsx similarity index 100% rename from web/src/components/UpgradeDialog.js rename to web/src/components/UpgradeDialog.jsx diff --git a/web/src/components/i18n.js b/web/src/components/i18n.jsx similarity index 100% rename from web/src/components/i18n.js rename to web/src/components/i18n.jsx diff --git a/web/src/index.js b/web/src/index.jsx similarity index 100% rename from web/src/index.js rename to web/src/index.jsx From 8319f1cf26113167fb29fe12edaff5db74caf35f Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Wed, 24 May 2023 09:03:28 +0200 Subject: [PATCH 61/97] Run eslint autofixes --- web/src/app/AccountApi.js | 40 ++++----- web/src/app/Api.js | 11 +-- web/src/app/Connection.js | 3 +- web/src/app/ConnectionManager.js | 13 ++- web/src/app/SubscriptionManager.js | 22 ++--- web/src/app/config.js | 2 +- web/src/app/errors.js | 4 + web/src/app/utils.js | 88 ++++++++----------- web/src/components/Account.jsx | 53 ++++++------ web/src/components/ActionBar.jsx | 16 ++-- web/src/components/App.jsx | 55 ++++++------ web/src/components/AttachmentIcon.jsx | 7 +- web/src/components/AvatarBox.jsx | 34 ++++---- web/src/components/DialogFooter.jsx | 44 +++++----- web/src/components/EmojiPicker.jsx | 8 +- web/src/components/ErrorBoundary.jsx | 9 +- web/src/components/Login.jsx | 8 +- web/src/components/Messaging.jsx | 12 +-- web/src/components/Navigation.jsx | 22 +++-- web/src/components/Notifications.jsx | 102 ++++++++++++----------- web/src/components/PopupMenu.jsx | 4 +- web/src/components/Pref.jsx | 8 +- web/src/components/Preferences.jsx | 46 +++++----- web/src/components/PublishDialog.jsx | 52 +++++------- web/src/components/ReserveDialogs.jsx | 16 ++-- web/src/components/ReserveIcons.jsx | 16 +--- web/src/components/Signup.jsx | 8 +- web/src/components/SubscribeDialog.jsx | 22 +++-- web/src/components/SubscriptionPopup.jsx | 40 ++++----- web/src/components/UpgradeDialog.jsx | 60 +++++++------ web/src/components/hooks.js | 2 +- web/src/components/styles.js | 2 +- 32 files changed, 394 insertions(+), 435 deletions(-) diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 9af220a..d3d5d4b 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -1,3 +1,4 @@ +import i18n from "i18next"; import { accountBillingPortalUrl, accountBillingSubscriptionUrl, @@ -17,7 +18,6 @@ import { } from "./utils"; import session from "./Session"; import subscriptionManager from "./SubscriptionManager"; -import i18n from "i18next"; import prefs from "./Prefs"; import routes from "../components/routes"; import { fetchOrThrow, UnauthorizedError } from "./errors"; @@ -66,13 +66,13 @@ class AccountApi { async create(username, password) { const url = accountUrl(config.base_url); const body = JSON.stringify({ - username: username, - password: password, + username, + password, }); console.log(`[AccountApi] Creating user account ${url}`); await fetchOrThrow(url, { method: "POST", - body: body, + body, }); } @@ -97,7 +97,7 @@ class AccountApi { method: "DELETE", headers: withBearerAuth({}, session.token()), body: JSON.stringify({ - password: password, + password, }), }); } @@ -118,7 +118,7 @@ class AccountApi { async createToken(label, expires) { const url = accountTokenUrl(config.base_url); const body = { - label: label, + label, expires: expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0, }; console.log(`[AccountApi] Creating user access token ${url}`); @@ -132,8 +132,8 @@ class AccountApi { async updateToken(token, label, expires) { const url = accountTokenUrl(config.base_url); const body = { - token: token, - label: label, + token, + label, }; if (expires > 0) { body.expires = Math.floor(Date.now() / 1000) + expires; @@ -171,7 +171,7 @@ class AccountApi { await fetchOrThrow(url, { method: "PATCH", headers: withBearerAuth({}, session.token()), - body: body, + body, }); } @@ -179,13 +179,13 @@ class AccountApi { const url = accountSubscriptionUrl(config.base_url); const body = JSON.stringify({ base_url: baseUrl, - topic: topic, + topic, }); console.log(`[AccountApi] Adding user subscription ${url}: ${body}`); const response = await fetchOrThrow(url, { method: "POST", headers: withBearerAuth({}, session.token()), - body: body, + body, }); const subscription = await response.json(); // May throw SyntaxError console.log(`[AccountApi] Subscription`, subscription); @@ -196,14 +196,14 @@ class AccountApi { const url = accountSubscriptionUrl(config.base_url); const body = JSON.stringify({ base_url: baseUrl, - topic: topic, + topic, ...payload, }); console.log(`[AccountApi] Updating user subscription ${url}: ${body}`); const response = await fetchOrThrow(url, { method: "PATCH", headers: withBearerAuth({}, session.token()), - body: body, + body, }); const subscription = await response.json(); // May throw SyntaxError console.log(`[AccountApi] Subscription`, subscription); @@ -230,8 +230,8 @@ class AccountApi { method: "POST", headers: withBearerAuth({}, session.token()), body: JSON.stringify({ - topic: topic, - everyone: everyone, + topic, + everyone, }), }); } @@ -272,11 +272,11 @@ class AccountApi { async upsertBillingSubscription(method, tier, interval) { const url = accountBillingSubscriptionUrl(config.base_url); const response = await fetchOrThrow(url, { - method: method, + method, headers: withBearerAuth({}, session.token()), body: JSON.stringify({ - tier: tier, - interval: interval, + tier, + interval, }), }); return await response.json(); // May throw SyntaxError @@ -309,7 +309,7 @@ class AccountApi { headers: withBearerAuth({}, session.token()), body: JSON.stringify({ number: phoneNumber, - channel: channel, + channel, }), }); } @@ -322,7 +322,7 @@ class AccountApi { headers: withBearerAuth({}, session.token()), body: JSON.stringify({ number: phoneNumber, - code: code, + code, }), }); } diff --git a/web/src/app/Api.js b/web/src/app/Api.js index b956e0b..ba1cbe6 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -18,7 +18,7 @@ class Api { const messages = []; const headers = maybeWithAuth({}, user); console.log(`[Api] Polling ${url}`); - for await (let line of fetchLinesIterator(url, headers)) { + for await (const line of fetchLinesIterator(url, headers)) { const message = JSON.parse(line); if (message.id) { console.log(`[Api, ${shortUrl}] Received message ${line}`); @@ -33,8 +33,8 @@ class Api { console.log(`[Api] Publishing message to ${topicUrl(baseUrl, topic)}`); const headers = {}; const body = { - topic: topic, - message: message, + topic, + message, ...options, }; await fetchOrThrow(baseUrl, { @@ -60,7 +60,7 @@ class Api { publishXHR(url, body, headers, onProgress) { console.log(`[Api] Publishing message to ${url}`); const xhr = new XMLHttpRequest(); - const send = new Promise(function (resolve, reject) { + const send = new Promise((resolve, reject) => { xhr.open("PUT", url); if (body.type) { xhr.overrideMimeType(body.type); @@ -106,7 +106,8 @@ class Api { }); if (response.status >= 200 && response.status <= 299) { return true; - } else if (response.status === 401 || response.status === 403) { + } + if (response.status === 401 || response.status === 403) { // See server/server.go return false; } diff --git a/web/src/app/Connection.js b/web/src/app/Connection.js index 7b25467..dd3cf63 100644 --- a/web/src/app/Connection.js +++ b/web/src/app/Connection.js @@ -77,7 +77,7 @@ class Connection { close() { console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`); const socket = this.ws; - const retryTimeout = this.retryTimeout; + const { retryTimeout } = this; if (socket !== null) { socket.close(); } @@ -110,6 +110,7 @@ class Connection { export class ConnectionState { static Connected = "connected"; + static Connecting = "connecting"; } diff --git a/web/src/app/ConnectionManager.js b/web/src/app/ConnectionManager.js index f50ed53..f6316aa 100644 --- a/web/src/app/ConnectionManager.js +++ b/web/src/app/ConnectionManager.js @@ -55,12 +55,12 @@ class ConnectionManager { // Create and add new connections subscriptionsWithUsersAndConnectionId.forEach((subscription) => { const subscriptionId = subscription.id; - const connectionId = subscription.connectionId; + const { connectionId } = subscription; const added = !this.connections.get(connectionId); if (added) { - const baseUrl = subscription.baseUrl; - const topic = subscription.topic; - const user = subscription.user; + const { baseUrl } = subscription; + const { topic } = subscription; + const { user } = subscription; const since = subscription.last; const connection = new Connection( connectionId, @@ -112,9 +112,8 @@ class ConnectionManager { } } -const makeConnectionId = async (subscription, user) => { - return user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`); -}; +const makeConnectionId = async (subscription, user) => + user ? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`) : hashCode(`${subscription.id}`); const connectionManager = new ConnectionManager(); export default connectionManager; diff --git a/web/src/app/SubscriptionManager.js b/web/src/app/SubscriptionManager.js index a539362..aeec3fc 100644 --- a/web/src/app/SubscriptionManager.js +++ b/web/src/app/SubscriptionManager.js @@ -25,8 +25,8 @@ class SubscriptionManager { } const subscription = { id: topicUrl(baseUrl, topic), - baseUrl: baseUrl, - topic: topic, + baseUrl, + topic, mutedUntil: 0, last: null, internal: internal || false, @@ -39,14 +39,14 @@ class SubscriptionManager { console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions); // Add remote subscriptions - let remoteIds = []; // = topicUrl(baseUrl, topic) + const remoteIds = []; // = topicUrl(baseUrl, topic) for (let i = 0; i < remoteSubscriptions.length; i++) { const remote = remoteSubscriptions[i]; const local = await this.add(remote.base_url, remote.topic, false); const reservation = remoteReservations?.find((r) => remote.base_url === config.base_url && remote.topic === r.topic) || null; await this.update(local.id, { displayName: remote.display_name, // May be undefined - reservation: reservation, // May be null! + reservation, // May be null! }); remoteIds.push(local.id); } @@ -63,12 +63,12 @@ class SubscriptionManager { } async updateState(subscriptionId, state) { - db.subscriptions.update(subscriptionId, { state: state }); + db.subscriptions.update(subscriptionId, { state }); } async remove(subscriptionId) { await db.subscriptions.delete(subscriptionId); - await db.notifications.where({ subscriptionId: subscriptionId }).delete(); + await db.notifications.where({ subscriptionId }).delete(); } async first() { @@ -140,7 +140,7 @@ class SubscriptionManager { } async deleteNotifications(subscriptionId) { - await db.notifications.where({ subscriptionId: subscriptionId }).delete(); + await db.notifications.where({ subscriptionId }).delete(); } async markNotificationRead(notificationId) { @@ -148,24 +148,24 @@ class SubscriptionManager { } async markNotificationsRead(subscriptionId) { - await db.notifications.where({ subscriptionId: subscriptionId, new: 1 }).modify({ new: 0 }); + await db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 }); } async setMutedUntil(subscriptionId, mutedUntil) { await db.subscriptions.update(subscriptionId, { - mutedUntil: mutedUntil, + mutedUntil, }); } async setDisplayName(subscriptionId, displayName) { await db.subscriptions.update(subscriptionId, { - displayName: displayName, + displayName, }); } async setReservation(subscriptionId, reservation) { await db.subscriptions.update(subscriptionId, { - reservation: reservation, + reservation, }); } diff --git a/web/src/app/config.js b/web/src/app/config.js index 15225f5..24e86f3 100644 --- a/web/src/app/config.js +++ b/web/src/app/config.js @@ -1,4 +1,4 @@ -const config = window.config; +const { config } = window; // The backend returns an empty base_url for the config struct, // so the frontend (hey, that's us!) can use the current location. diff --git a/web/src/app/errors.js b/web/src/app/errors.js index e31949d..0d44375 100644 --- a/web/src/app/errors.js +++ b/web/src/app/errors.js @@ -48,6 +48,7 @@ export class UnauthorizedError extends Error { export class UserExistsError extends Error { static CODE = 40901; // errHTTPConflictUserExists + constructor() { super("Username already exists"); } @@ -55,6 +56,7 @@ export class UserExistsError extends Error { export class TopicReservedError extends Error { static CODE = 40902; // errHTTPConflictTopicReserved + constructor() { super("Topic already reserved"); } @@ -62,6 +64,7 @@ export class TopicReservedError extends Error { export class AccountCreateLimitReachedError extends Error { static CODE = 42906; // errHTTPTooManyRequestsLimitAccountCreation + constructor() { super("Account creation limit reached"); } @@ -69,6 +72,7 @@ export class AccountCreateLimitReachedError extends Error { export class IncorrectPasswordError extends Error { static CODE = 40026; // errHTTPBadRequestIncorrectPasswordConfirmation + constructor() { super("Password incorrect"); } diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 88e3684..e8c98ec 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -1,3 +1,4 @@ +import { Base64 } from "js-base64"; import { rawEmojis } from "./emojis"; import beep from "../sounds/beep.mp3"; import juntos from "../sounds/juntos.mp3"; @@ -7,7 +8,6 @@ import dadum from "../sounds/dadum.mp3"; import pop from "../sounds/pop.mp3"; import popSwoosh from "../sounds/pop-swoosh.mp3"; import config from "./config"; -import { Base64 } from "js-base64"; export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`; export const topicUrlWs = (baseUrl, topic) => @@ -33,9 +33,7 @@ export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; export const expandSecureUrl = (url) => `https://${url}`; -export const validUrl = (url) => { - return url.match(/^https?:\/\/.+/); -}; +export const validUrl = (url) => url.match(/^https?:\/\/.+/); export const validTopic = (topic) => { if (disallowedTopic(topic)) { @@ -44,14 +42,13 @@ export const validTopic = (topic) => { return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app! }; -export const disallowedTopic = (topic) => { - return config.disallowed_topics.includes(topic); -}; +export const disallowedTopic = (topic) => config.disallowed_topics.includes(topic); export const topicDisplayName = (subscription) => { if (subscription.displayName) { return subscription.displayName; - } else if (subscription.baseUrl === config.base_url) { + } + if (subscription.baseUrl === config.base_url) { return subscription.topic; } return topicShortUrl(subscription.baseUrl, subscription.topic); @@ -67,7 +64,7 @@ rawEmojis.forEach((emoji) => { const toEmojis = (tags) => { if (!tags) return []; - else return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]); + return tags.filter((tag) => tag in emojis).map((tag) => emojis[tag]); }; export const formatTitleWithDefault = (m, fallback) => { @@ -81,33 +78,31 @@ export const formatTitle = (m) => { const emojiList = toEmojis(m.tags); if (emojiList.length > 0) { return `${emojiList.join(" ")} ${m.title}`; - } else { - return m.title; } + return m.title; }; export const formatMessage = (m) => { if (m.title) { return m.message; - } else { - const emojiList = toEmojis(m.tags); - if (emojiList.length > 0) { - return `${emojiList.join(" ")} ${m.message}`; - } else { - return m.message; - } } + const emojiList = toEmojis(m.tags); + if (emojiList.length > 0) { + return `${emojiList.join(" ")} ${m.message}`; + } + return m.message; }; export const unmatchedTags = (tags) => { if (!tags) return []; - else return tags.filter((tag) => !(tag in emojis)); + return tags.filter((tag) => !(tag in emojis)); }; export const maybeWithAuth = (headers, user) => { if (user && user.password) { return withBasicAuth(headers, user.username, user.password); - } else if (user && user.token) { + } + if (user && user.token) { return withBearerAuth(headers, user.token); } return headers; @@ -121,30 +116,22 @@ export const maybeWithBearerAuth = (headers, token) => { }; export const withBasicAuth = (headers, username, password) => { - headers["Authorization"] = basicAuth(username, password); + headers.Authorization = basicAuth(username, password); return headers; }; -export const basicAuth = (username, password) => { - return `Basic ${encodeBase64(`${username}:${password}`)}`; -}; +export const basicAuth = (username, password) => `Basic ${encodeBase64(`${username}:${password}`)}`; export const withBearerAuth = (headers, token) => { - headers["Authorization"] = bearerAuth(token); + headers.Authorization = bearerAuth(token); return headers; }; -export const bearerAuth = (token) => { - return `Bearer ${token}`; -}; +export const bearerAuth = (token) => `Bearer ${token}`; -export const encodeBase64 = (s) => { - return Base64.encode(s); -}; +export const encodeBase64 = (s) => Base64.encode(s); -export const encodeBase64Url = (s) => { - return Base64.encodeURI(s); -}; +export const encodeBase64Url = (s) => Base64.encodeURI(s); export const maybeAppendActionErrors = (message, notification) => { const actionErrors = (notification.actions ?? []) @@ -153,13 +140,13 @@ export const maybeAppendActionErrors = (message, notification) => { .join("\n"); if (actionErrors.length === 0) { return message; - } else { - return `${message}\n\n${actionErrors}`; } + return `${message}\n\n${actionErrors}`; }; export const shuffle = (arr) => { - let j, x; + let j; + let x; for (let index = arr.length - 1; index > 0; index--) { j = Math.floor(Math.random() * (index + 1)); x = arr[index]; @@ -169,12 +156,11 @@ export const shuffle = (arr) => { return arr; }; -export const splitNoEmpty = (s, delimiter) => { - return s +export const splitNoEmpty = (s, delimiter) => + s .split(delimiter) .map((x) => x.trim()) .filter((x) => x !== ""); -}; /** Non-cryptographic hash function, see https://stackoverflow.com/a/8831937/1440785 */ export const hashCode = async (s) => { @@ -182,21 +168,18 @@ export const hashCode = async (s) => { for (let i = 0; i < s.length; i++) { const char = s.charCodeAt(i); hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer + hash &= hash; // Convert to 32bit integer } return hash; }; -export const formatShortDateTime = (timestamp) => { - return new Intl.DateTimeFormat("default", { +export const formatShortDateTime = (timestamp) => + new Intl.DateTimeFormat("default", { dateStyle: "short", timeStyle: "short", }).format(new Date(timestamp * 1000)); -}; -export const formatShortDate = (timestamp) => { - return new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000)); -}; +export const formatShortDate = (timestamp) => new Intl.DateTimeFormat("default", { dateStyle: "short" }).format(new Date(timestamp * 1000)); export const formatBytes = (bytes, decimals = 2) => { if (bytes === 0) return "0 bytes"; @@ -204,13 +187,14 @@ export const formatBytes = (bytes, decimals = 2) => { const dm = decimals < 0 ? 0 : decimals; const sizes = ["bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; + return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`; }; export const formatNumber = (n) => { if (n === 0) { return n; - } else if (n % 1000 === 0) { + } + if (n % 1000 === 0) { return `${n / 1000}k`; } return n.toLocaleString(); @@ -267,7 +251,7 @@ export const playSound = async (id) => { export async function* fetchLinesIterator(fileURL, headers) { const utf8Decoder = new TextDecoder("utf-8"); const response = await fetch(fileURL, { - headers: headers, + headers, }); const reader = response.body.getReader(); let { value: chunk, done: readerDone } = await reader.read(); @@ -277,12 +261,12 @@ export async function* fetchLinesIterator(fileURL, headers) { let startIndex = 0; for (;;) { - let result = re.exec(chunk); + const result = re.exec(chunk); if (!result) { if (readerDone) { break; } - let remainder = chunk.substr(startIndex); + const remainder = chunk.substr(startIndex); ({ value: chunk, done: readerDone } = await reader.read()); chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : ""); startIndex = re.lastIndex = 0; diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 5cb68c1..d6f7484 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -29,34 +29,34 @@ import Container from "@mui/material/Container"; import Card from "@mui/material/Card"; import Button from "@mui/material/Button"; import { Trans, useTranslation } from "react-i18next"; -import session from "../app/Session"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; -import theme from "./theme"; import Dialog from "@mui/material/Dialog"; import DialogTitle from "@mui/material/DialogTitle"; import DialogContent from "@mui/material/DialogContent"; import TextField from "@mui/material/TextField"; -import routes from "./routes"; import IconButton from "@mui/material/IconButton"; -import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils"; -import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; -import { Pref, PrefGroup } from "./Pref"; -import db from "../app/db"; import i18n from "i18next"; import humanizeDuration from "humanize-duration"; -import UpgradeDialog from "./UpgradeDialog"; import CelebrationIcon from "@mui/icons-material/Celebration"; -import { AccountContext } from "./App"; -import DialogFooter from "./DialogFooter"; -import { Paragraph } from "./styles"; import CloseIcon from "@mui/icons-material/Close"; import { ContentCopy, Public } from "@mui/icons-material"; import MenuItem from "@mui/material/MenuItem"; import DialogContentText from "@mui/material/DialogContentText"; +import AddIcon from "@mui/icons-material/Add"; +import routes from "./routes"; +import { formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils"; +import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi"; +import { Pref, PrefGroup } from "./Pref"; +import db from "../app/db"; +import UpgradeDialog from "./UpgradeDialog"; +import { AccountContext } from "./App"; +import DialogFooter from "./DialogFooter"; +import { Paragraph } from "./styles"; import { IncorrectPasswordError, UnauthorizedError } from "../app/errors"; import { ProChip } from "./SubscriptionPopup"; -import AddIcon from "@mui/icons-material/Add"; +import theme from "./theme"; +import session from "../app/Session"; const Account = () => { if (!session.exists()) { @@ -561,9 +561,7 @@ const Stats = () => { return <>; } - const normalize = (value, max) => { - return Math.min((value / max) * 100, 100); - }; + const normalize = (value, max) => Math.min((value / max) * 100, 100); return ( @@ -746,18 +744,16 @@ const Stats = () => { ); }; -const InfoIcon = () => { - return ( - - ); -}; +const InfoIcon = () => ( + +); const Tokens = () => { const { t } = useTranslation(); @@ -814,7 +810,8 @@ const TokensTable = (props) => { const tokens = (props.tokens || []).sort((a, b) => { if (a.token === session.token()) { return -1; - } else if (b.token === session.token()) { + } + if (b.token === session.token()) { return 1; } return a.token.localeCompare(b.token); diff --git a/web/src/components/ActionBar.jsx b/web/src/components/ActionBar.jsx index 24aef72..c9853df 100644 --- a/web/src/components/ActionBar.jsx +++ b/web/src/components/ActionBar.jsx @@ -1,5 +1,4 @@ import AppBar from "@mui/material/AppBar"; -import Navigation from "./Navigation"; import Toolbar from "@mui/material/Toolbar"; import IconButton from "@mui/material/IconButton"; import MenuIcon from "@mui/icons-material/Menu"; @@ -7,23 +6,24 @@ import Typography from "@mui/material/Typography"; import * as React from "react"; import { useState } from "react"; import Box from "@mui/material/Box"; -import { topicDisplayName } from "../app/utils"; -import db from "../app/db"; import { useLocation, useNavigate } from "react-router-dom"; import MenuItem from "@mui/material/MenuItem"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import NotificationsIcon from "@mui/icons-material/Notifications"; import NotificationsOffIcon from "@mui/icons-material/NotificationsOff"; -import routes from "./routes"; -import subscriptionManager from "../app/SubscriptionManager"; -import logo from "../img/ntfy.svg"; import { useTranslation } from "react-i18next"; -import session from "../app/Session"; import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import Button from "@mui/material/Button"; import Divider from "@mui/material/Divider"; import { Logout, Person, Settings } from "@mui/icons-material"; import ListItemIcon from "@mui/material/ListItemIcon"; +import session from "../app/Session"; +import logo from "../img/ntfy.svg"; +import subscriptionManager from "../app/SubscriptionManager"; +import routes from "./routes"; +import db from "../app/db"; +import { topicDisplayName } from "../app/utils"; +import Navigation from "./Navigation"; import accountApi from "../app/AccountApi"; import PopupMenu from "./PopupMenu"; import { SubscriptionPopup } from "./SubscriptionPopup"; @@ -86,7 +86,7 @@ const ActionBar = (props) => { const SettingsIcons = (props) => { const { t } = useTranslation(); const [anchorEl, setAnchorEl] = useState(null); - const subscription = props.subscription; + const { subscription } = props; const handleToggleMute = async () => { const mutedUntil = subscription.mutedUntil ? 0 : 1; // Make this a timestamp in the future diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 50f2ad6..661f6eb 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -4,16 +4,17 @@ import Box from "@mui/material/Box"; import { ThemeProvider } from "@mui/material/styles"; import CssBaseline from "@mui/material/CssBaseline"; import Toolbar from "@mui/material/Toolbar"; +import { useLiveQuery } from "dexie-react-hooks"; +import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom"; +import { Backdrop, CircularProgress } from "@mui/material"; import { AllSubscriptions, SingleSubscription } from "./Notifications"; import theme from "./theme"; import Navigation from "./Navigation"; import ActionBar from "./ActionBar"; import notifier from "../app/Notifier"; import Preferences from "./Preferences"; -import { useLiveQuery } from "dexie-react-hooks"; import subscriptionManager from "../app/SubscriptionManager"; import userManager from "../app/UserManager"; -import { BrowserRouter, Outlet, Route, Routes, useParams } from "react-router-dom"; import { expandUrl } from "../app/utils"; import ErrorBoundary from "./ErrorBoundary"; import routes from "./routes"; @@ -21,7 +22,6 @@ import { useAccountListener, useBackgroundProcesses, useConnectionListeners } fr import PublishDialog from "./PublishDialog"; import Messaging from "./Messaging"; import "./i18n"; // Translations! -import { Backdrop, CircularProgress } from "@mui/material"; import Login from "./Login"; import Signup from "./Signup"; import Account from "./Account"; @@ -66,12 +66,11 @@ const Layout = () => { const subscriptions = useLiveQuery(() => subscriptionManager.all()); const subscriptionsWithoutInternal = subscriptions?.filter((s) => !s.internal); const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0; - const [selected] = (subscriptionsWithoutInternal || []).filter((s) => { - return ( + const [selected] = (subscriptionsWithoutInternal || []).filter( + (s) => (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic) || (config.base_url === s.baseUrl && params.topic === s.topic) - ); - }); + ); useConnectionListeners(account, subscriptions, users); useAccountListener(setAccount); @@ -95,7 +94,7 @@ const Layout = () => { @@ -104,30 +103,28 @@ const Layout = () => { ); }; -const Main = (props) => { - return ( - (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]), - }} - > - {props.children} - - ); -}; +const Main = (props) => ( + (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]), + }} + > + {props.children} + +); const Loader = () => ( (theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900]), diff --git a/web/src/components/AttachmentIcon.jsx b/web/src/components/AttachmentIcon.jsx index 9939b3b..4d4e428 100644 --- a/web/src/components/AttachmentIcon.jsx +++ b/web/src/components/AttachmentIcon.jsx @@ -1,16 +1,17 @@ import * as React from "react"; import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; import fileDocument from "../img/file-document.svg"; import fileImage from "../img/file-image.svg"; import fileVideo from "../img/file-video.svg"; import fileAudio from "../img/file-audio.svg"; import fileApp from "../img/file-app.svg"; -import { useTranslation } from "react-i18next"; const AttachmentIcon = (props) => { const { t } = useTranslation(); - const type = props.type; - let imageFile, imageLabel; + const { type } = props; + let imageFile; + let imageLabel; if (!type) { imageFile = fileDocument; imageLabel = t("notifications_attachment_file_image"); diff --git a/web/src/components/AvatarBox.jsx b/web/src/components/AvatarBox.jsx index 506ae63..470fcae 100644 --- a/web/src/components/AvatarBox.jsx +++ b/web/src/components/AvatarBox.jsx @@ -3,23 +3,21 @@ import { Avatar } from "@mui/material"; import Box from "@mui/material/Box"; import logo from "../img/ntfy-filled.svg"; -const AvatarBox = (props) => { - return ( - - - {props.children} - - ); -}; +const AvatarBox = (props) => ( + + + {props.children} + +); export default AvatarBox; diff --git a/web/src/components/DialogFooter.jsx b/web/src/components/DialogFooter.jsx index 5a2bd7a..2ddd7fb 100644 --- a/web/src/components/DialogFooter.jsx +++ b/web/src/components/DialogFooter.jsx @@ -3,31 +3,29 @@ import Box from "@mui/material/Box"; import DialogContentText from "@mui/material/DialogContentText"; import DialogActions from "@mui/material/DialogActions"; -const DialogFooter = (props) => { - return ( - ( + + - - {props.status} - - {props.children} - - ); -}; + {props.status} + + {props.children} + +); export default DialogFooter; diff --git a/web/src/components/EmojiPicker.jsx b/web/src/components/EmojiPicker.jsx index 04cc5c7..6aa8e3c 100644 --- a/web/src/components/EmojiPicker.jsx +++ b/web/src/components/EmojiPicker.jsx @@ -1,15 +1,15 @@ import * as React from "react"; import { useRef, useState } from "react"; import Typography from "@mui/material/Typography"; -import { rawEmojis } from "../app/emojis"; import Box from "@mui/material/Box"; import TextField from "@mui/material/TextField"; import { ClickAwayListener, Fade, InputAdornment, styled } from "@mui/material"; import IconButton from "@mui/material/IconButton"; import { Close } from "@mui/icons-material"; import Popper from "@mui/material/Popper"; -import { splitNoEmpty } from "../app/utils"; import { useTranslation } from "react-i18next"; +import { splitNoEmpty } from "../app/utils"; +import { rawEmojis } from "../app/emojis"; // Create emoji list by category and create a search base (string with all search words) // @@ -28,7 +28,7 @@ rawEmojis.forEach((emoji) => { const supportedEmoji = unicodeVersion <= maxSupportedVersionForDesktopChrome || !isDesktopChrome; if (supportedEmoji) { const searchBase = `${emoji.description.toLowerCase()} ${emoji.aliases.join(" ")} ${emoji.tags.join(" ")}`; - const emojiWithSearchBase = { ...emoji, searchBase: searchBase }; + const emojiWithSearchBase = { ...emoji, searchBase }; emojisByCategory[emoji.category].push(emojiWithSearchBase); } } catch (e) { @@ -133,7 +133,7 @@ const Category = (props) => { }; const Emoji = (props) => { - const emoji = props.emoji; + const { emoji } = props; const matches = emojiMatches(emoji, props.search); const title = `${emoji.description} (${emoji.aliases[0]})`; return ( diff --git a/web/src/components/ErrorBoundary.jsx b/web/src/components/ErrorBoundary.jsx index 21ee6a9..a8e6762 100644 --- a/web/src/components/ErrorBoundary.jsx +++ b/web/src/components/ErrorBoundary.jsx @@ -46,9 +46,9 @@ class ErrorBoundaryImpl extends React.Component { // Fetch additional info and a better stack trace StackTrace.fromError(error).then((stack) => { console.error("[ErrorBoundary] Stacktrace fetched", stack); - const niceStack = - `${error.toString()}\n` + - stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n"); + const niceStack = `${error.toString()}\n${stack + .map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`) + .join("\n")}`; this.setState({ niceStack }); }); } @@ -73,9 +73,8 @@ class ErrorBoundaryImpl extends React.Component { if (this.state.error) { if (this.state.unsupportedIndexedDB) { return this.renderUnsupportedIndexedDB(); - } else { - return this.renderError(); } + return this.renderError(); } return this.props.children; } diff --git a/web/src/components/Login.jsx b/web/src/components/Login.jsx index ce4f3b5..57cf16e 100644 --- a/web/src/components/Login.jsx +++ b/web/src/components/Login.jsx @@ -5,15 +5,15 @@ import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import TextField from "@mui/material/TextField"; import Button from "@mui/material/Button"; import Box from "@mui/material/Box"; -import routes from "./routes"; -import session from "../app/Session"; import { NavLink } from "react-router-dom"; -import AvatarBox from "./AvatarBox"; import { useTranslation } from "react-i18next"; -import accountApi from "../app/AccountApi"; import IconButton from "@mui/material/IconButton"; import { InputAdornment } from "@mui/material"; import { Visibility, VisibilityOff } from "@mui/icons-material"; +import accountApi from "../app/AccountApi"; +import AvatarBox from "./AvatarBox"; +import session from "../app/Session"; +import routes from "./routes"; import { UnauthorizedError } from "../app/errors"; const Login = () => { diff --git a/web/src/components/Messaging.jsx b/web/src/components/Messaging.jsx index b6ed952..cf91bbb 100644 --- a/web/src/components/Messaging.jsx +++ b/web/src/components/Messaging.jsx @@ -1,21 +1,21 @@ import * as React from "react"; import { useState } from "react"; -import Navigation from "./Navigation"; import Paper from "@mui/material/Paper"; import IconButton from "@mui/material/IconButton"; import TextField from "@mui/material/TextField"; import SendIcon from "@mui/icons-material/Send"; -import api from "../app/Api"; -import PublishDialog from "./PublishDialog"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import { Portal, Snackbar } from "@mui/material"; import { useTranslation } from "react-i18next"; +import PublishDialog from "./PublishDialog"; +import api from "../app/Api"; +import Navigation from "./Navigation"; const Messaging = (props) => { const [message, setMessage] = useState(""); const [dialogKey, setDialogKey] = useState(0); - const dialogOpenMode = props.dialogOpenMode; + const { dialogOpenMode } = props; const subscription = props.selected; const handleOpenDialogClick = () => { @@ -39,7 +39,7 @@ const Messaging = (props) => { topic={subscription?.topic ?? ""} message={message} onClose={handleDialogClose} - onDragEnter={() => props.onDialogOpenModeChange((prev) => (prev ? prev : PublishDialog.OPEN_MODE_DRAG))} // Only update if not already open + onDragEnter={() => props.onDialogOpenModeChange((prev) => prev || PublishDialog.OPEN_MODE_DRAG)} // Only update if not already open onResetOpenMode={() => props.onDialogOpenModeChange(PublishDialog.OPEN_MODE_DEFAULT)} /> @@ -48,7 +48,7 @@ const Messaging = (props) => { const MessageBar = (props) => { const { t } = useTranslation(); - const subscription = props.subscription; + const { subscription } = props; const [snackOpen, setSnackOpen] = useState(false); const handleSendClick = async () => { try { diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx index 1eeb3e8..8135362 100644 --- a/web/src/components/Navigation.jsx +++ b/web/src/components/Navigation.jsx @@ -11,28 +11,28 @@ import Divider from "@mui/material/Divider"; import List from "@mui/material/List"; import SettingsIcon from "@mui/icons-material/Settings"; import AddIcon from "@mui/icons-material/Add"; -import SubscribeDialog from "./SubscribeDialog"; import { Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Portal, Tooltip } from "@mui/material"; import Button from "@mui/material/Button"; import Typography from "@mui/material/Typography"; +import { useLocation, useNavigate } from "react-router-dom"; +import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material"; +import Box from "@mui/material/Box"; +import ArticleIcon from "@mui/icons-material/Article"; +import { Trans, useTranslation } from "react-i18next"; +import CelebrationIcon from "@mui/icons-material/Celebration"; +import IconButton from "@mui/material/IconButton"; +import SubscribeDialog from "./SubscribeDialog"; import { openUrl, topicDisplayName, topicUrl } from "../app/utils"; import routes from "./routes"; import { ConnectionState } from "../app/Connection"; -import { useLocation, useNavigate } from "react-router-dom"; import subscriptionManager from "../app/SubscriptionManager"; -import { ChatBubble, MoreVert, NotificationsOffOutlined, Send } from "@mui/icons-material"; -import Box from "@mui/material/Box"; import notifier from "../app/Notifier"; import config from "../app/config"; -import ArticleIcon from "@mui/icons-material/Article"; -import { Trans, useTranslation } from "react-i18next"; import session from "../app/Session"; import accountApi, { Permission, Role } from "../app/AccountApi"; -import CelebrationIcon from "@mui/icons-material/Celebration"; import UpgradeDialog from "./UpgradeDialog"; import { AccountContext } from "./App"; import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons"; -import IconButton from "@mui/material/IconButton"; import { SubscriptionPopup } from "./SubscriptionPopup"; const navWidth = 280; @@ -237,9 +237,7 @@ const UpgradeBanner = () => { const SubscriptionList = (props) => { const sortedSubscriptions = props.subscriptions .filter((s) => !s.internal) - .sort((a, b) => { - return topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1; - }); + .sort((a, b) => (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic) ? -1 : 1)); return ( <> {sortedSubscriptions.map((subscription) => ( @@ -258,7 +256,7 @@ const SubscriptionItem = (props) => { const navigate = useNavigate(); const [menuAnchorEl, setMenuAnchorEl] = useState(null); - const subscription = props.subscription; + const { subscription } = props; const iconBadge = subscription.new <= 99 ? subscription.new : "99+"; const displayName = topicDisplayName(subscription); const ariaLabel = subscription.state === ConnectionState.Connecting ? `${displayName} (${t("nav_button_connecting")})` : displayName; diff --git a/web/src/components/Notifications.jsx b/web/src/components/Notifications.jsx index 35fd080..5b611fb 100644 --- a/web/src/components/Notifications.jsx +++ b/web/src/components/Notifications.jsx @@ -4,6 +4,15 @@ import Card from "@mui/material/Card"; import Typography from "@mui/material/Typography"; import * as React from "react"; import { useEffect, useState } from "react"; +import IconButton from "@mui/material/IconButton"; +import CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; +import { useLiveQuery } from "dexie-react-hooks"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { Trans, useTranslation } from "react-i18next"; +import { useOutletContext } from "react-router-dom"; import { formatBytes, formatMessage, @@ -15,23 +24,14 @@ import { topicShortUrl, unmatchedTags, } from "../app/utils"; -import IconButton from "@mui/material/IconButton"; -import CheckIcon from "@mui/icons-material/Check"; -import CloseIcon from "@mui/icons-material/Close"; import { LightboxBackdrop, Paragraph, VerticallyCenteredContainer } from "./styles"; -import { useLiveQuery } from "dexie-react-hooks"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; import subscriptionManager from "../app/SubscriptionManager"; -import InfiniteScroll from "react-infinite-scroll-component"; import priority1 from "../img/priority-1.svg"; import priority2 from "../img/priority-2.svg"; import priority4 from "../img/priority-4.svg"; import priority5 from "../img/priority-5.svg"; import logoOutline from "../img/ntfy-outline.svg"; import AttachmentIcon from "./AttachmentIcon"; -import { Trans, useTranslation } from "react-i18next"; -import { useOutletContext } from "react-router-dom"; import { useAutoSubscribe } from "./hooks"; export const AllSubscriptions = () => { @@ -52,46 +52,50 @@ export const SingleSubscription = () => { }; const AllSubscriptionsList = (props) => { - const subscriptions = props.subscriptions; + const { subscriptions } = props; const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []); if (notifications === null || notifications === undefined) { return ; - } else if (subscriptions.length === 0) { + } + if (subscriptions.length === 0) { return ; - } else if (notifications.length === 0) { + } + if (notifications.length === 0) { return ; } return ; }; const SingleSubscriptionList = (props) => { - const subscription = props.subscription; + const { subscription } = props; const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]); if (notifications === null || notifications === undefined) { return ; - } else if (notifications.length === 0) { + } + if (notifications.length === 0) { return ; } - return ; + return ; }; const NotificationList = (props) => { const { t } = useTranslation(); const pageSize = 20; - const notifications = props.notifications; + const { notifications } = props; const [snackOpen, setSnackOpen] = useState(false); const [maxCount, setMaxCount] = useState(pageSize); const count = Math.min(notifications.length, maxCount); - useEffect(() => { - return () => { + useEffect( + () => () => { setMaxCount(pageSize); const main = document.getElementById("main"); if (main) { main.scrollTo(0, 0); } - }; - }, [props.id]); + }, + [props.id] + ); return ( { const NotificationItem = (props) => { const { t } = useTranslation(); - const notification = props.notification; - const attachment = notification.attachment; + const { notification } = props; + const { attachment } = notification; const date = formatShortDateTime(notification.time); const otherTags = unmatchedTags(notification.tags); const tags = otherTags.length > 0 ? otherTags.join(", ") : null; @@ -272,7 +276,7 @@ const priorityFiles = { const Attachment = (props) => { const { t } = useTranslation(); - const attachment = props.attachment; + const { attachment } = props; const expired = attachment.expires && attachment.expires < Date.now() / 1000; const expires = attachment.expires && attachment.expires > Date.now() / 1000; const displayableImage = !expired && attachment.type && attachment.type.startsWith("image/"); @@ -402,20 +406,18 @@ const Image = (props) => { ); }; -const UserActions = (props) => { - return ( - <> - {props.notification.actions.map((action) => ( - - ))} - - ); -}; +const UserActions = (props) => ( + <> + {props.notification.actions.map((action) => ( + + ))} + +); const UserAction = (props) => { const { t } = useTranslation(); - const notification = props.notification; - const action = props.action; + const { notification } = props; + const { action } = props; if (action.action === "broadcast") { return ( @@ -426,7 +428,8 @@ const UserAction = (props) => { ); - } else if (action.action === "view") { + } + if (action.action === "view") { return ( ); - } else if (action.action === "http") { + } + if (action.action === "http") { const method = action.method ?? "POST"; const label = action.label + (ACTION_LABEL_SUFFIX[action.progress ?? 0] ?? ""); return (