diff options
author | Gabe Kangas <gabek@real-ity.com> | 2022-01-12 10:31:57 -0800 |
---|---|---|
committer | Gabe Kangas <gabek@real-ity.com> | 2022-01-12 11:04:38 -0800 |
commit | a585a9785d7eb4d8664024587302f77226f62a69 (patch) | |
tree | 0c627d21632112e23928cb2919d440d37280781c | |
parent | 20409eb14db8fb14e4746a14c5d5b97b8784a9f4 (diff) |
First pass at browser, discord, twilio notifications
36 files changed, 1253 insertions, 134 deletions
diff --git a/build/javascript/package.json b/build/javascript/package.json index 1c9bcbb57..d5d7e55cf 100644 --- a/build/javascript/package.json +++ b/build/javascript/package.json @@ -26,6 +26,7 @@ "@joeattardi/emoji-button", "htm", "preact", + "preact/hooks", "mark.js/dist/mark.es6.min.js", "tailwindcss/dist/tailwind.min.css", "micromodal/dist/micromodal.min.js" diff --git a/controllers/admin/notifications.go b/controllers/admin/notifications.go new file mode 100644 index 000000000..d8ead438f --- /dev/null +++ b/controllers/admin/notifications.go @@ -0,0 +1,76 @@ +package admin + +import ( + "encoding/json" + "net/http" + + "github.com/owncast/owncast/controllers" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" +) + +// SetDiscordNotificationConfiguration will set the discord notification configuration. +func SetDiscordNotificationConfiguration(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + type request struct { + Value models.DiscordConfiguration `json:"value"` + } + + decoder := json.NewDecoder(r.Body) + var config request + if err := decoder.Decode(&config); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update discord config with provided values") + return + } + + if err := data.SetDiscordConfig(config.Value); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update discord config with provided values") + } +} + +// SetBrowserNotificationConfiguration will set the browser notification configuration. +func SetBrowserNotificationConfiguration(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + type request struct { + Value models.BrowserNotificationConfiguration `json:"value"` + } + + decoder := json.NewDecoder(r.Body) + var config request + if err := decoder.Decode(&config); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update browser push config with provided values") + return + } + + if err := data.SetBrowserPushConfig(config.Value); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update browser push config with provided values") + } +} + +// SetTwilioNotificationConfiguration will set the twilio notification configuration. +func SetTwilioNotificationConfiguration(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + type request struct { + Value models.TwilioConfiguration `json:"value"` + } + + decoder := json.NewDecoder(r.Body) + var config request + if err := decoder.Decode(&config); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update s3 config with provided values") + return + } + + if err := data.SetTwilioConfig(config.Value); err != nil { + controllers.WriteSimpleResponse(w, false, "unable to update twilio config with provided values") + } +} diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go index a408f5e63..d7f093aed 100644 --- a/controllers/admin/serverConfig.go +++ b/controllers/admin/serverConfig.go @@ -72,6 +72,11 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { ShowEngagement: data.GetFederationShowEngagement(), BlockedDomains: data.GetBlockedFederatedDomains(), }, + Notifications: notificationsConfigResponse{ + Discord: data.GetDiscordConfig(), + Twilio: data.GetTwilioConfig(), + Browser: data.GetBrowserPushConfig(), + }, } w.Header().Set("Content-Type", "application/json") @@ -83,21 +88,22 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { } type serverConfigAdminResponse struct { - InstanceDetails webConfigResponse `json:"instanceDetails"` - FFmpegPath string `json:"ffmpegPath"` - StreamKey string `json:"streamKey"` - WebServerPort int `json:"webServerPort"` - WebServerIP string `json:"webServerIP"` - RTMPServerPort int `json:"rtmpServerPort"` - S3 models.S3 `json:"s3"` - VideoSettings videoSettings `json:"videoSettings"` - YP yp `json:"yp"` - ChatDisabled bool `json:"chatDisabled"` - ExternalActions []models.ExternalAction `json:"externalActions"` - SupportedCodecs []string `json:"supportedCodecs"` - VideoCodec string `json:"videoCodec"` - ForbiddenUsernames []string `json:"forbiddenUsernames"` - Federation federationConfigResponse `json:"federation"` + InstanceDetails webConfigResponse `json:"instanceDetails"` + FFmpegPath string `json:"ffmpegPath"` + StreamKey string `json:"streamKey"` + WebServerPort int `json:"webServerPort"` + WebServerIP string `json:"webServerIP"` + RTMPServerPort int `json:"rtmpServerPort"` + S3 models.S3 `json:"s3"` + VideoSettings videoSettings `json:"videoSettings"` + YP yp `json:"yp"` + ChatDisabled bool `json:"chatDisabled"` + ExternalActions []models.ExternalAction `json:"externalActions"` + SupportedCodecs []string `json:"supportedCodecs"` + VideoCodec string `json:"videoCodec"` + ForbiddenUsernames []string `json:"forbiddenUsernames"` + Federation federationConfigResponse `json:"federation"` + Notifications notificationsConfigResponse `json:"notifications"` } type videoSettings struct { @@ -106,17 +112,18 @@ type videoSettings struct { } type webConfigResponse struct { - Name string `json:"name"` - Summary string `json:"summary"` - WelcomeMessage string `json:"welcomeMessage"` - Logo string `json:"logo"` - Tags []string `json:"tags"` - Version string `json:"version"` - NSFW bool `json:"nsfw"` - ExtraPageContent string `json:"extraPageContent"` - StreamTitle string `json:"streamTitle"` // What's going on with the current stream - SocialHandles []models.SocialHandle `json:"socialHandles"` - CustomStyles string `json:"customStyles"` + Name string `json:"name"` + Summary string `json:"summary"` + WelcomeMessage string `json:"welcomeMessage"` + Logo string `json:"logo"` + Tags []string `json:"tags"` + Version string `json:"version"` + NSFW bool `json:"nsfw"` + ExtraPageContent string `json:"extraPageContent"` + StreamTitle string `json:"streamTitle"` // What's going on with the current stream + SocialHandles []models.SocialHandle `json:"socialHandles"` + CustomStyles string `json:"customStyles"` + Notifications notificationsConfigResponse `json:"notifications"` } type yp struct { @@ -133,3 +140,9 @@ type federationConfigResponse struct { ShowEngagement bool `json:"showEngagement"` BlockedDomains []string `json:"blockedDomains"` } + +type notificationsConfigResponse struct { + Browser models.BrowserNotificationConfiguration `json:"browser"` + Twilio models.TwilioConfiguration `json:"twilio"` + Discord models.DiscordConfiguration `json:"discord"` +} diff --git a/controllers/config.go b/controllers/config.go index b91152bdc..5203a1bca 100644 --- a/controllers/config.go +++ b/controllers/config.go @@ -12,23 +12,25 @@ import ( "github.com/owncast/owncast/models" "github.com/owncast/owncast/router/middleware" "github.com/owncast/owncast/utils" + log "github.com/sirupsen/logrus" ) type webConfigResponse struct { - Name string `json:"name"` - Summary string `json:"summary"` - Logo string `json:"logo"` - Tags []string `json:"tags"` - Version string `json:"version"` - NSFW bool `json:"nsfw"` - ExtraPageContent string `json:"extraPageContent"` - StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream - SocialHandles []models.SocialHandle `json:"socialHandles"` - ChatDisabled bool `json:"chatDisabled"` - ExternalActions []models.ExternalAction `json:"externalActions"` - CustomStyles string `json:"customStyles"` - MaxSocketPayloadSize int `json:"maxSocketPayloadSize"` - Federation federationConfigResponse `json:"federation"` + Name string `json:"name"` + Summary string `json:"summary"` + Logo string `json:"logo"` + Tags []string `json:"tags"` + Version string `json:"version"` + NSFW bool `json:"nsfw"` + ExtraPageContent string `json:"extraPageContent"` + StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream + SocialHandles []models.SocialHandle `json:"socialHandles"` + ChatDisabled bool `json:"chatDisabled"` + ExternalActions []models.ExternalAction `json:"externalActions"` + CustomStyles string `json:"customStyles"` + MaxSocketPayloadSize int `json:"maxSocketPayloadSize"` + Federation federationConfigResponse `json:"federation"` + Notifications notificationsConfigResponse `json:"notifications"` } type federationConfigResponse struct { @@ -37,6 +39,20 @@ type federationConfigResponse struct { FollowerCount int `json:"followerCount,omitempty"` } +type browserNotificationsConfigResponse struct { + Enabled bool `json:"enabled"` + PublicKey string `json:"publicKey,omitempty"` +} + +type textMessageNotificatoinsConfigResponse struct { + Enabled bool `json:"enabled"` +} + +type notificationsConfigResponse struct { + Browser browserNotificationsConfigResponse `json:"browser"` + TextMessages textMessageNotificatoinsConfigResponse `json:"textMessages"` +} + // GetWebConfig gets the status of the server. func GetWebConfig(w http.ResponseWriter, r *http.Request) { middleware.EnableCors(w) @@ -71,6 +87,23 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) { } } + browserPushEnabled := data.GetBrowserPushConfig().Enabled + browserPushPublicKey, err := data.GetBrowserPushPublicKey() + if err != nil { + log.Errorln("unable to fetch browser push notifications public key", err) + browserPushEnabled = false + } + + notificationsResponse := notificationsConfigResponse{ + Browser: browserNotificationsConfigResponse{ + Enabled: browserPushEnabled, + PublicKey: browserPushPublicKey, + }, + TextMessages: textMessageNotificatoinsConfigResponse{ + Enabled: data.GetTwilioConfig().Enabled, + }, + } + configuration := webConfigResponse{ Name: data.GetServerName(), Summary: serverSummary, @@ -86,6 +119,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) { CustomStyles: data.GetCustomStyles(), MaxSocketPayloadSize: config.MaxSocketPayloadSize, Federation: federationResponse, + Notifications: notificationsResponse, } if err := json.NewEncoder(w).Encode(configuration); err != nil { diff --git a/controllers/notifications.go b/controllers/notifications.go new file mode 100644 index 000000000..de060fb85 --- /dev/null +++ b/controllers/notifications.go @@ -0,0 +1,48 @@ +package controllers + +import ( + "encoding/json" + "net/http" + + "github.com/owncast/owncast/notifications" + "github.com/owncast/owncast/utils" + + log "github.com/sirupsen/logrus" +) + +// RegisterForLiveNotifications will register a channel + destination to be +// notified when a stream goes live. +func RegisterForLiveNotifications(w http.ResponseWriter, r *http.Request) { + if r.Method != POST { + WriteSimpleResponse(w, false, r.Method+" not supported") + return + } + + type request struct { + // Channel is the notification channel (browser, sms, etc) + Channel string `json:"channel"` + // Destination is the target of the notification in the above channel. + Destination string `json:"destination"` + } + + decoder := json.NewDecoder(r.Body) + var req request + if err := decoder.Decode(&req); err != nil { + log.Errorln(err) + WriteSimpleResponse(w, false, "unable to register for notifications") + } + + // Make sure the requested channel is one we want to handle. + validTypes := []string{notifications.BrowserPushNotification, notifications.TextMessageNotification} + _, validChannel := utils.FindInSlice(validTypes, req.Channel) + if !validChannel { + WriteSimpleResponse(w, false, "invalid notification channel: "+req.Channel) + return + } + + if err := notifications.AddNotification(req.Channel, req.Destination); err != nil { + log.Errorln(err) + WriteSimpleResponse(w, false, "unable to save notification") + return + } +} diff --git a/core/core.go b/core/core.go index 4926eab61..b01ede6b1 100644 --- a/core/core.go +++ b/core/core.go @@ -15,6 +15,7 @@ import ( "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/core/webhooks" "github.com/owncast/owncast/models" + "github.com/owncast/owncast/notifications" "github.com/owncast/owncast/utils" "github.com/owncast/owncast/yp" ) @@ -25,11 +26,8 @@ var ( _transcoder *transcoder.Transcoder _yp *yp.YP _broadcaster *models.Broadcaster -) - -var ( - handler transcoder.HLSHandler - fileWriter = transcoder.FileWriterReceiverService{} + handler transcoder.HLSHandler + fileWriter = transcoder.FileWriterReceiverService{} ) // Start starts up the core processing. @@ -80,6 +78,8 @@ func Start() error { webhooks.InitWorkerPool() + notifications.Setup(data.GetStore()) + return nil } diff --git a/core/data/config.go b/core/data/config.go index 5bbb4ed1c..5ba076c45 100644 --- a/core/data/config.go +++ b/core/data/config.go @@ -14,44 +14,50 @@ import ( ) const ( - extraContentKey = "extra_page_content" - streamTitleKey = "stream_title" - streamKeyKey = "stream_key" - logoPathKey = "logo_path" - serverSummaryKey = "server_summary" - serverWelcomeMessageKey = "server_welcome_message" - serverNameKey = "server_name" - serverURLKey = "server_url" - httpPortNumberKey = "http_port_number" - httpListenAddressKey = "http_listen_address" - rtmpPortNumberKey = "rtmp_port_number" - serverMetadataTagsKey = "server_metadata_tags" - directoryEnabledKey = "directory_enabled" - directoryRegistrationKeyKey = "directory_registration_key" - socialHandlesKey = "social_handles" - peakViewersSessionKey = "peak_viewers_session" - peakViewersOverallKey = "peak_viewers_overall" - lastDisconnectTimeKey = "last_disconnect_time" - ffmpegPathKey = "ffmpeg_path" - nsfwKey = "nsfw" - s3StorageEnabledKey = "s3_storage_enabled" - s3StorageConfigKey = "s3_storage_config" - videoLatencyLevel = "video_latency_level" - videoStreamOutputVariantsKey = "video_stream_output_variants" - chatDisabledKey = "chat_disabled" - externalActionsKey = "external_actions" - customStylesKey = "custom_styles" - videoCodecKey = "video_codec" - blockedUsernamesKey = "blocked_usernames" - publicKeyKey = "public_key" - privateKeyKey = "private_key" - serverInitDateKey = "server_init_date" - federationEnabledKey = "federation_enabled" - federationUsernameKey = "federation_username" - federationPrivateKey = "federation_private" - federationGoLiveMessageKey = "federation_go_live_message" - federationShowEngagementKey = "federation_show_engagement" - federationBlockedDomainsKey = "federation_blocked_domains" + extraContentKey = "extra_page_content" + streamTitleKey = "stream_title" + streamKeyKey = "stream_key" + logoPathKey = "logo_path" + serverSummaryKey = "server_summary" + serverWelcomeMessageKey = "server_welcome_message" + serverNameKey = "server_name" + serverURLKey = "server_url" + httpPortNumberKey = "http_port_number" + httpListenAddressKey = "http_listen_address" + rtmpPortNumberKey = "rtmp_port_number" + serverMetadataTagsKey = "server_metadata_tags" + directoryEnabledKey = "directory_enabled" + directoryRegistrationKeyKey = "directory_registration_key" + socialHandlesKey = "social_handles" + peakViewersSessionKey = "peak_viewers_session" + peakViewersOverallKey = "peak_viewers_overall" + lastDisconnectTimeKey = "last_disconnect_time" + ffmpegPathKey = "ffmpeg_path" + nsfwKey = "nsfw" + s3StorageConfigKey = "s3_storage_config" + videoLatencyLevel = "video_latency_level" + videoStreamOutputVariantsKey = "video_stream_output_variants" + chatDisabledKey = "chat_disabled" + externalActionsKey = "external_actions" + customStylesKey = "custom_styles" + videoCodecKey = "video_codec" + blockedUsernamesKey = "blocked_usernames" + publicKeyKey = "public_key" + privateKeyKey = "private_key" + serverInitDateKey = "server_init_date" + federationEnabledKey = "federation_enabled" + federationUsernameKey = "federation_username" + federationPrivateKey = "federation_private" + federationGoLiveMessageKey = "federation_go_live_message" + federationShowEngagementKey = "federation_show_engagement" + federationBlockedDomainsKey = "federation_blocked_domains" + notificationsEnabledKey = "notifications_enabled" + twilioConfigurationKey = "twilio_configuration" + discordConfigurationKey = "discord_configuration" + browserPushConfigurationKey = "browser_push_configuration" + browserPushPublicKeyKey = "browser_push_public_key" + browserPushPrivateKeyKey = "browser_push_private_key" + hasConfiguredInitialNotificationsKey = "has_configured_initial_notifications" ) // GetExtraPageBodyContent will return the user-supplied body content. @@ -413,22 +419,6 @@ func SetS3Config(config models.S3) error { return _datastore.Save(configEntry) } -// GetS3StorageEnabled will return if external storage is enabled. -func GetS3StorageEnabled() bool { - enabled, err := _datastore.GetBool(s3StorageEnabledKey) - if err != nil { - log.Traceln(err) - return false - } - - return enabled -} - -// SetS3StorageEnabled will enable or disable external storage. -func SetS3StorageEnabled(enabled bool) error { - return _datastore.SetBool(s3StorageEnabledKey, enabled) -} - // GetStreamLatencyLevel will return the stream latency level. func GetStreamLatencyLevel() models.LatencyLevel { level, err := _datastore.GetNumber(videoLatencyLevel) @@ -733,3 +723,108 @@ func GetBlockedFederatedDomains() []string { return strings.Split(domains, ",") } + +// SetNotificationsEnabled will save the enabled state of notifications. +func SetNotificationsEnabled(enabled bool) error { + return _datastore.SetBool(notificationsEnabledKey, enabled) +} + +// GetNotificationsEnabled will return the enabled state of notifications. +func GetNotificationsEnabled() bool { + enabled, _ := _datastore.GetBool(notificationsEnabledKey) + return enabled +} + +// GetTwilioConfig will return the Twilio configuration. +func GetTwilioConfig() models.TwilioConfiguration { + configEntry, err := _datastore.Get(twilioConfigurationKey) + if err != nil { + return models.TwilioConfiguration{Enabled: false} + } + + var config models.TwilioConfiguration + if err := configEntry.getObject(&config); err != nil { + return models.TwilioConfiguration{Enabled: false} + } + + return config +} + +// SetTwilioConfig will set the Twilio configuration. +func SetTwilioConfig(config models.TwilioConfiguration) error { + configEntry := ConfigEntry{Key: twilioConfigurationKey, Value: config} + return _datastore.Save(configEntry) +} + +// GetDiscordConfig will return the Discord configuration. +func GetDiscordConfig() models.DiscordConfiguration { + configEntry, err := _datastore.Get(discordConfigurationKey) + if err != nil { + return models.DiscordConfiguration{Enabled: false} + } + + var config models.DiscordConfiguration + if err := configEntry.getObject(&config); err != nil { + return models.DiscordConfiguration{Enabled: false} + } + + return config +} + +// SetDiscordConfig will set the Discord configuration. +func SetDiscordConfig(config models.DiscordConfiguration) error { + configEntry := ConfigEntry{Key: discordConfigurationKey, Value: config} + return _datastore.Save(configEntry) +} + +// GetDiscordConfig will return the browser push configuration. +func GetBrowserPushConfig() models.BrowserNotificationConfiguration { + configEntry, err := _datastore.Get(browserPushConfigurationKey) + if err != nil { + return models.BrowserNotificationConfiguration{Enabled: false} + } + + var config models.BrowserNotificationConfiguration + if err := configEntry.getObject(&config); err != nil { + return models.BrowserNotificationConfiguration{Enabled: false} + } + + return config +} + +// SetBrowserPushConfig will set the browser push configuration. +func SetBrowserPushConfig(config models.BrowserNotificationConfiguration) error { + configEntry := ConfigEntry{Key: browserPushConfigurationKey, Value: config} + return _datastore.Save(configEntry) +} + +// SetBrowserPushPublicKey will set the public key for browser pushes. +func SetBrowserPushPublicKey(key string) error { + return _datastore.SetString(browserPushPublicKeyKey, key) +} + +// GetBrowserPushPublicKey will return the public key for browser pushes. +func GetBrowserPushPublicKey() (string, error) { + return _datastore.GetString(browserPushPublicKeyKey) +} + +// SetBrowserPushPrivateKey will set the private key for browser pushes. +func SetBrowserPushPrivateKey(key string) error { + return _datastore.SetString(browserPushPrivateKeyKey, key) +} + +// GetBrowserPushPrivateKey will return the private key for browser pushes. +func GetBrowserPushPrivateKey() (string, error) { + return _datastore.GetString(browserPushPrivateKeyKey) +} + +// SetHasPerformedInitialNotificationsConfig sets when performed initial setup. +func SetHasPerformedInitialNotificationsConfig(hasConfigured bool) error { + return _datastore.SetBool(hasConfiguredInitialNotificationsKey, true) +} + +// GetHasPerformedInitialNotificationsConfig gets when performed initial setup. +func GetHasPerformedInitialNotificationsConfig() bool { + configured, _ := _datastore.GetBool(hasConfiguredInitialNotificationsKey) + return configured +} diff --git a/core/streamState.go b/core/streamState.go index f4184f7d2..c65f8c8a7 100644 --- a/core/streamState.go +++ b/core/streamState.go @@ -15,6 +15,7 @@ import ( "github.com/owncast/owncast/core/transcoder" "github.com/owncast/owncast/core/webhooks" "github.com/owncast/owncast/models" + "github.com/owncast/owncast/notifications" "github.com/owncast/owncast/utils" ) @@ -28,6 +29,8 @@ var _currentBroadcast *models.CurrentBroadcast var _onlineTimerCancelFunc context.CancelFunc +var _lastNotified *time.Time + // setStreamAsConnected sets the stream as connected. func setStreamAsConnected(rtmpOut *io.PipeReader) { now := utils.NullTime{Time: time.Now(), Valid: true} @@ -75,6 +78,15 @@ func setStreamAsConnected(rtmpOut *io.PipeReader) { if data.GetFederationEnabled() { _onlineTimerCancelFunc = startFederatedLiveStreamMessageTimer() } + + // Send alerts to those who have registered for notifications. + if notifier, err := notifications.New(data.GetDatastore()); err != nil { + log.Errorln(err) + } else if _lastNotified == nil || time.Since(*_lastNotified) > 5*time.Minute { + notifier.Notify() + now := time.Now() + _lastNotified = &now + } } // SetStreamAsDisconnected sets the stream as disconnected. diff --git a/db/models.go b/db/models.go index d17aecca1..493e19d5c 100644 --- a/db/models.go +++ b/db/models.go @@ -34,3 +34,10 @@ type ApOutbox struct { CreatedAt sql.NullTime LiveNotification sql.NullBool } + +type Notification struct { + ID int32 + Channel string + Destination string + CreatedAt sql.NullTime +} diff --git a/db/query.sql b/db/query.sql index 7aae2d51f..f54cf8dc1 100644 --- a/db/query.sql +++ b/db/query.sql @@ -55,3 +55,9 @@ SELECT count(*) FROM ap_accepted_activities WHERE iri = $1 AND actor = $2 AND TY -- name: UpdateFollowerByIRI :exec UPDATE ap_followers SET inbox = $1, name = $2, username = $3, image = $4 WHERE iri = $5; + +-- name: AddNotification :exec +INSERT INTO notifications (channel, destination) VALUES($1, $2); + +-- name: GetNotificationDestinationsForChannel :many +SELECT destination FROM notifications WHERE channel = $1; diff --git a/db/query.sql.go b/db/query.sql.go index 7cec05aa6..aaf1abf51 100644 --- a/db/query.sql.go +++ b/db/query.sql.go @@ -36,6 +36,20 @@ func (q *Queries) AddFollower(ctx context.Context, arg AddFollowerParams) error return err } +const addNotification = `-- name: AddNotification :exec +INSERT INTO notifications (channel, destination) VALUES($1, $2) +` + +type AddNotificationParams struct { + Channel string + Destination string +} + +func (q *Queries) AddNotification(ctx context.Context, arg AddNotificationParams) error { + _, err := q.db.ExecContext(ctx, addNotification, arg.Channel, arg.Destination) + return err +} + const addToAcceptedActivities = `-- name: AddToAcceptedActivities :exec INSERT INTO ap_accepted_activities(iri, actor, type, timestamp) values($1, $2, $3, $4) ` @@ -291,6 +305,33 @@ func (q *Queries) GetLocalPostCount(ctx context.Context) (int64, error) { return count, err } +const getNotificationDestinationsForChannel = `-- name: GetNotificationDestinationsForChannel :many +SELECT destination FROM notifications WHERE channel = $1 +` + +func (q *Queries) GetNotificationDestinationsForChannel(ctx context.Context, channel string) ([]string, error) { + rows, err := q.db.QueryContext(ctx, getNotificationDestinationsForChannel, channel) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var destination string + if err := rows.Scan(&destination); err != nil { + return nil, err + } + items = append(items, destination) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getObjectFromOutboxByID = `-- name: GetObjectFromOutboxByID :one SELECT value FROM ap_outbox WHERE iri = $1 ` diff --git a/db/schema.sql b/db/schema.sql index 739888031..1c95b70aa 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -35,3 +35,10 @@ CREATE TABLE IF NOT EXISTS ap_accepted_activities ( "timestamp" TIMESTAMP NOT NULL ); CREATE INDEX iri_actor_index ON ap_accepted_activities (iri,actor); + +CREATE TABLE IF NOT EXISTS notifications ( + "id" INTEGER NOT NULL PRIMARY KEY, + "channel" TEXT NOT NULL, + "destination" TEXT NOT NULL, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); + CREATE INDEX channel_index ON notifications (channel); @@ -7,7 +7,6 @@ require ( github.com/aws/aws-sdk-go v1.42.0 github.com/go-fed/activity v1.0.1-0.20210803212804-d866ba75dd0f github.com/go-fed/httpsig v1.1.0 - github.com/go-ole/go-ole v1.2.6 // indirect github.com/gorilla/websocket v1.4.2 github.com/grafov/m3u8 v0.11.1 github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible @@ -28,21 +27,33 @@ require ( ) require ( - github.com/aymerick/douceur v0.2.0 // indirect - github.com/gorilla/css v1.0.0 // indirect - github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/jonboulle/clockwork v0.2.2 // indirect github.com/lestrrat-go/strftime v1.0.4 // indirect github.com/mvdan/xurls v1.1.0 // indirect - github.com/oschwald/maxminddb-golang v1.8.0 // indirect github.com/pkg/errors v0.9.1 github.com/tklauser/go-sysconf v0.3.5 // indirect - github.com/tklauser/numcpus v0.2.2 // indirect github.com/yusufpapurcu/wmi v1.2.2 // indirect - golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect - golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect - golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 // indirect + golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect +) + +require github.com/SherClockHolmes/webpush-go v1.1.3 + +require github.com/twilio/twilio-go v0.19.0 + +require ( + github.com/aymerick/douceur v0.2.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-test/deep v1.0.4 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/gorilla/css v1.0.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/oschwald/maxminddb-golang v1.8.0 // indirect + github.com/tklauser/numcpus v0.2.2 // indirect + golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect + golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) replace github.com/go-fed/activity => github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026 @@ -1,9 +1,12 @@ +github.com/SherClockHolmes/webpush-go v1.1.3 h1:VucRA0rOs0fWQGaf2sp1oeKa8om9Mo5OMaRpUiCxzQE= +github.com/SherClockHolmes/webpush-go v1.1.3/go.mod h1:w6X47YApe/B9wUz2Wh8xukxlyupaxSSEbu6yKJcHN2w= github.com/amalfra/etag v0.0.0-20190921100247-cafc8de96bc5 h1:+ty4KYpIDUhjzsVsV+HiTeYEfufBc/4FLNiqIGU1A1U= github.com/amalfra/etag v0.0.0-20190921100247-cafc8de96bc5/go.mod h1:Qk51jPgvIaO549MR+IvLP/uMZbZGs05QJSzEhDVZ1jc= github.com/aws/aws-sdk-go v1.42.0 h1:BMZws0t8NAhHFsfnT3B40IwD13jVDG5KerlRksctVIw= github.com/aws/aws-sdk-go v1.42.0/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/dave/jennifer v1.3.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -13,9 +16,14 @@ github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg= github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= +github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= @@ -28,6 +36,9 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8= github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is= github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible h1:Y6sqxHMyB1D2YSzWkLibYKgg+SwmyFU9dF2hn6MdTj4= @@ -45,12 +56,11 @@ github.com/mvdan/xurls v1.1.0 h1:OpuDelGQ1R1ueQ6sSryzi6P+1RtBpfQHM8fJwlE45ww= github.com/mvdan/xurls v1.1.0/go.mod h1:tQlNn3BED8bE/15hnSL2HLkDeLWpNPAwtw7wkEq44oU= github.com/nareix/joy5 v0.0.0-20200712071056-a55089207c88 h1:CXq5QLPMcfGEZMx8uBMyLdDiUNV72vlkSiyqg+jf7AI= github.com/nareix/joy5 v0.0.0-20200712071056-a55089207c88/go.mod h1:XmAOs6UJXpNXRwKk+KY/nv5kL6xXYXyellk+A1pTlko= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oschwald/geoip2-golang v1.5.0 h1:igg2yQIrrcRccB1ytFXqBfOHCjXWIoMv85lVJ1ONZzw= github.com/oschwald/geoip2-golang v1.5.0/go.mod h1:xdvYt5xQzB8ORWFqPnqMwZpCpgNagttWdoZLlJQzg7s= github.com/oschwald/maxminddb-golang v1.8.0 h1:Uh/DSnGoxsyp/KYbY1AuP0tYEwfs0sCph9p/UMXK/Hk= github.com/oschwald/maxminddb-golang v1.8.0/go.mod h1:RXZtst0N6+FY/3qCNmZMBApR19cdQj43/NM9VkrNAis= -github.com/owncast/activity v1.0.1-0.20210908225327-e46ee45ec09c h1:lk78BK8sLYn8nwy4ZZdQqcRdkagxQI//wF/DXuxsg1Y= -github.com/owncast/activity v1.0.1-0.20210908225327-e46ee45ec09c/go.mod h1:v4QoPaAzjWZ8zN2VFVGL5ep9C02mst0hQYHUpQwso4Q= github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026 h1:E1nxiX44BcMQTSSs8MHLm2rXnqXNedYZkFI31gXMsJc= github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026/go.mod h1:v4QoPaAzjWZ8zN2VFVGL5ep9C02mst0hQYHUpQwso4Q= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -80,23 +90,34 @@ github.com/tklauser/go-sysconf v0.3.5 h1:uu3Xl4nkLzQfXNsWn15rPc/HQCJKObbt1dKJeWp github.com/tklauser/go-sysconf v0.3.5/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI= github.com/tklauser/numcpus v0.2.2 h1:oyhllyrScuYI6g+h/zUvNXNp1wy7x8qQy3t/piefldA= github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM= +github.com/twilio/twilio-go v0.19.0 h1:ksf9y3WQlR8x5xDjRNC3bnoFBrjbEYCqiIEoH1vb4Pk= +github.com/twilio/twilio-go v0.19.0/go.mod h1:cDIj6rv7xDcQy6Ss/hB0Tz9901wJbWK4XPw0gnt3U9Y= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs= github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg= github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M= +golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -105,24 +126,35 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191224085550-c709ea063b76/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 h1:hZR0X1kPW+nwyJ9xRxqZk1vx5RUObAPBdKVvXPDUH/E= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 h1:id054HUawV2/6IGm2IV8KZQjqtwAOo2CYlOToYqa0d0= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= mvdan.cc/xurls v1.1.0 h1:kj0j2lonKseISJCiq1Tfk+iTv65dDGCl0rTbanXJGGc= mvdan.cc/xurls v1.1.0/go.mod h1:TNWuhvo+IqbUCmtUIb/3LJSQdrzel8loVpgFm0HikbI= @@ -16,16 +16,18 @@ import ( "github.com/owncast/owncast/utils" ) -var dbFile = flag.String("database", "", "Path to the database file.") -var logDirectory = flag.String("logdir", "", "Directory where logs will be written to") -var backupDirectory = flag.String("backupdir", "", "Directory where backups will be written to") -var enableDebugOptions = flag.Bool("enableDebugFeatures", false, "Enable additional debugging options.") -var enableVerboseLogging = flag.Bool("enableVerboseLogging", false, "Enable additional logging.") -var restoreDatabaseFile = flag.String("restoreDatabase", "", "Restore an Owncast database backup") -var newStreamKey = flag.String("streamkey", "", "Set your stream key/admin password") -var webServerPortOverride = flag.String("webserverport", "", "Force the web server to listen on a specific port") -var webServerIPOverride = flag.String("webserverip", "", "Force web server to listen on this IP address") -var rtmpPortOverride = flag.Int("rtmpport", 0, "Set listen port for the RTMP server") +var ( + dbFile = flag.String("database", "", "Path to the database file.") + logDirectory = flag.String("logdir", "", "Directory where logs will be written to") + backupDirectory = flag.String("backupdir", "", "Directory where backups will be written to") + enableDebugOptions = flag.Bool("enableDebugFeatures", false, "Enable additional debugging options.") + enableVerboseLogging = flag.Bool("enableVerboseLogging", false, "Enable additional logging.") + restoreDatabaseFile = flag.String("restoreDatabase", "", "Restore an Owncast database backup") + newStreamKey = flag.String("streamkey", "", "Set your stream key/admin password") + webServerPortOverride = flag.String("webserverport", "", "Force the web server to listen on a specific port") + webServerIPOverride = flag.String("webserverip", "", "Force web server to listen on this IP address") + rtmpPortOverride = flag.Int("rtmpport", 0, "Set listen port for the RTMP server") +) func main() { flag.Parse() diff --git a/models/twilio.go b/models/twilio.go new file mode 100644 index 000000000..4894b0973 --- /dev/null +++ b/models/twilio.go @@ -0,0 +1,20 @@ +package models + +type TwilioConfiguration struct { + Enabled bool `json:"enabled"` + AccountSid string `json:"accountSid,omitempty"` + AuthToken string `json:"authToken,omitempty"` + PhoneNumber string `json:"phoneNumber,omitempty"` + GoLiveMessage string `json:"goLiveMessage,omitempty"` +} + +type DiscordConfiguration struct { + Enabled bool `json:"enabled"` + Webhook string `json:"webhook,omitempty"` + GoLiveMessage string `json:"goLiveMessage,omitempty"` +} + +type BrowserNotificationConfiguration struct { + Enabled bool `json:"enabled"` + GoLiveMessage string `json:"goLiveMessage,omitempty"` +} diff --git a/notifications/browser/browser.go b/notifications/browser/browser.go new file mode 100644 index 000000000..793c9286f --- /dev/null +++ b/notifications/browser/browser.go @@ -0,0 +1,79 @@ +package browser + +import ( + "encoding/json" + + "github.com/SherClockHolmes/webpush-go" + "github.com/owncast/owncast/core/data" + "github.com/pkg/errors" +) + +// Browser is an instance of the Browser service. +type Browser struct { + datastore *data.Datastore + privateKey string + publicKey string +} + +// New will create a new instance of the Browser service. +func New(datastore *data.Datastore, publicKey, privateKey string) (*Browser, error) { + return &Browser{ + datastore: datastore, + privateKey: privateKey, + publicKey: publicKey, + }, nil +} + +// GenerateBrowserPushKeys will create the VAPID keys required for web push notifications. +func GenerateBrowserPushKeys() (string, string, error) { + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + if err != nil { + return "", "", errors.Wrap(err, "error generating web push keys") + } + + return privateKey, publicKey, nil +} + +// Send will send a browser push notification to the given subscription. +func (b *Browser) Send( + subscription string, + title string, + body string, +) error { + type message struct { + Title string `json:"title"` + Body string `json:"body"` + Icon string `json:"icon"` + } + + m := message{ + Title: title, + Body: body, + Icon: "/logo/external", + } + + d, err := json.Marshal(m) + if err != nil { + return errors.Wrap(err, "error marshalling web push message") + } + + // Decode subscription + s := &webpush.Subscription{} + if err := json.Unmarshal([]byte(subscription), s); err != nil { + return errors.Wrap(err, "error decoding destination subscription") + } + + // Send Notification + resp, err := webpush.SendNotification(d, s, &webpush.Options{ + VAPIDPublicKey: b.publicKey, + VAPIDPrivateKey: b.privateKey, + Topic: "owncast-go-live", + TTL: 10, + }) + if err != nil { + return errors.Wrap(err, "error sending browser push notification") + } + defer resp.Body.Close() + + return err +} diff --git a/notifications/channels.go b/notifications/channels.go new file mode 100644 index 000000000..e63b2e5ba --- /dev/null +++ b/notifications/channels.go @@ -0,0 +1,6 @@ +package notifications + +const ( + BrowserPushNotification = "BROWSER_PUSH_NOTIFICATION" + TextMessageNotification = "TEXT_MESSAGE_NOTIFICATION" +) diff --git a/notifications/discord/discord.go b/notifications/discord/discord.go new file mode 100644 index 000000000..d0e5918a2 --- /dev/null +++ b/notifications/discord/discord.go @@ -0,0 +1,61 @@ +package discord + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/pkg/errors" +) + +// Discord is an instance of the Discord service. +type Discord struct { + name string + avatar string + webhookURL string +} + +// New will create a new instance of the Discord service. +func New(name, avatar, webhook string) (*Discord, error) { + return &Discord{ + name: name, + avatar: avatar, + webhookURL: webhook, + }, nil +} + +// Send will send a message to a Discord channel via a webhook. +func (t *Discord) Send(content string) error { + type message struct { + Username string `json:"username"` + Content string `json:"content"` + Avatar string `json:"avatar_url"` + } + + msg := message{ + Username: t.name, + Content: content, + Avatar: t.avatar, + } + + jsonText, err := json.Marshal(msg) + if err != nil { + return errors.Wrap(err, "error marshalling discord message to json") + } + + req, err := http.NewRequest("POST", t.webhookURL, bytes.NewReader(jsonText)) + if err != nil { + return errors.Wrap(err, "error creating discord webhook request") + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + + resp, err := client.Do(req) + if err != nil { + return errors.Wrap(err, "error executing discord webhook") + } + + return resp.Body.Close() +} diff --git a/notifications/notifications.go b/notifications/notifications.go new file mode 100644 index 000000000..2ad624025 --- /dev/null +++ b/notifications/notifications.go @@ -0,0 +1,150 @@ +package notifications + +import ( + "github.com/owncast/owncast/config" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/notifications/browser" + "github.com/owncast/owncast/notifications/discord" + "github.com/owncast/owncast/notifications/twilio" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +// Notifier is an instance of the live stream notifier. +type Notifier struct { + datastore *data.Datastore + browser *browser.Browser + discord *discord.Discord + twilio *twilio.Twilio +} + +// Setup will perform any pre-use setup for the notifier. +func Setup(datastore *data.Datastore) { + createNotificationsTable(datastore.DB) + + pubKey, _ := data.GetBrowserPushPublicKey() + privKey, _ := data.GetBrowserPushPrivateKey() + + // We need browser push keys so people can register for pushes. + if pubKey == "" || privKey == "" { + browserPrivateKey, browserPublicKey, err := browser.GenerateBrowserPushKeys() + if err != nil { + log.Errorln("unable to initialize browser push notification keys", err) + } + + if err := data.SetBrowserPushPrivateKey(browserPrivateKey); err != nil { + log.Errorln("unable to set browser push private key", err) + } + + if err := data.SetBrowserPushPublicKey(browserPublicKey); err != nil { + log.Errorln("unable to set browser push public key", err) + } + } + + // Enable browser push notifications by default. + if !data.GetHasPerformedInitialNotificationsConfig() { + _ = data.SetBrowserPushConfig(models.BrowserNotificationConfiguration{Enabled: true, GoLiveMessage: config.GetDefaults().FederationGoLiveMessage}) + _ = data.SetHasPerformedInitialNotificationsConfig(true) + } +} + +// New creates a new instance of the Notifier. +func New(datastore *data.Datastore) (*Notifier, error) { + notifier := Notifier{ + datastore: datastore, + } + + // Add browser push notifications + if data.GetBrowserPushConfig().Enabled { + publicKey, err := data.GetBrowserPushPublicKey() + if err != nil || publicKey == "" { + return nil, errors.Wrap(err, "notifier disabled, failed to get browser push public key") + } + + privateKey, err := data.GetBrowserPushPrivateKey() + if err != nil || privateKey == "" { + return nil, errors.Wrap(err, "notifier disabled, failed to get browser push private key") + } + + browserNotifier, err := browser.New(datastore, publicKey, privateKey) + if err != nil { + return nil, errors.Wrap(err, "error creating browser notifier") + } + notifier.browser = browserNotifier + } + + // Add discord notifications + discordConfig := data.GetDiscordConfig() + if discordConfig.Enabled && discordConfig.Webhook != "" { + var image string + if serverURL := data.GetServerURL(); serverURL != "" { + image = serverURL + "/images/owncast-logo.png" + } + discordNotifier, err := discord.New( + data.GetServerName(), + image, + discordConfig.Webhook, + ) + if err != nil { + return nil, errors.Wrap(err, "error creating discord notifier") + } + notifier.discord = discordNotifier + } + + // Add Twilio notifier + if twilioConfig := data.GetTwilioConfig(); twilioConfig.Enabled { + twilioNotifier, err := twilio.New(twilioConfig.PhoneNumber, twilioConfig.AccountSid, twilioConfig.AuthToken) + if err != nil { + return nil, errors.Wrap(err, "error creating twilio notifier") + } + notifier.twilio = twilioNotifier + } + + return ¬ifier, nil +} + +func (n *Notifier) notifyBrowserDestinations() { + destinations, err := GetNotificationDestinationsForChannel(BrowserPushNotification) + if err != nil { + log.Errorln("error getting browser push notification destinations", err) + } + for _, destination := range destinations { + if err := n.browser.Send(destination, data.GetServerName(), data.GetBrowserPushConfig().GoLiveMessage); err != nil { + log.Errorln(err) + } + } +} + +func (n *Notifier) notifyTwilioDestinations() { + destinations, err := GetNotificationDestinationsForChannel(TextMessageNotification) + if err != nil { + log.Errorln("error getting browser push notification destinations", err) + } + for _, destination := range destinations { + if err := n.twilio.Send(data.GetTwilioConfig().GoLiveMessage, destination); err != nil { + log.Errorln("error sending twilio message", err) + } + } +} + +func (n *Notifier) notifyDiscord() { + if err := n.discord.Send(data.GetDiscordConfig().GoLiveMessage); err != nil { + log.Errorln("error sending discord message", err) + } +} + +// Notify will fire the different notification channels. +func (n *Notifier) Notify() { + if n.browser != nil { + n.notifyBrowserDestinations() + } + + if n.discord != nil { + n.notifyDiscord() + } + + if n.twilio != nil { + n.notifyTwilioDestinations() + } +} diff --git a/notifications/persistence.go b/notifications/persistence.go new file mode 100644 index 000000000..bbfd88a5a --- /dev/null +++ b/notifications/persistence.go @@ -0,0 +1,51 @@ +package notifications + +import ( + "context" + "database/sql" + + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/db" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +func createNotificationsTable(db *sql.DB) { + log.Traceln("Creating federation followers table...") + + createTableSQL := `CREATE TABLE IF NOT EXISTS notifications ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "channel" TEXT NOT NULL, + "destination" TEXT NOT NULL, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); + CREATE INDEX channel_index ON notifications (channel);` + + stmt, err := db.Prepare(createTableSQL) + if err != nil { + log.Fatal(err) + } + defer stmt.Close() + _, err = stmt.Exec() + if err != nil { + log.Warnln("error executing sql creating followers table", createTableSQL, err) + } +} + +// AddNotification saves a new user notification destination. +func AddNotification(channel string, destination string) error { + return data.GetDatastore().GetQueries().AddNotification(context.Background(), db.AddNotificationParams{ + Channel: channel, + Destination: destination, + }) +} + +// GetNotificationDestinationsForChannel will return a collection of +// destinations to notify for a given channel. +func GetNotificationDestinationsForChannel(channel string) ([]string, error) { + result, err := data.GetDatastore().GetQueries().GetNotificationDestinationsForChannel(context.Background(), channel) + if err != nil { + return nil, errors.Wrap(err, "unable to query notification destinations for channel "+channel) + } + + return result, nil +} diff --git a/notifications/twilio/twilio.go b/notifications/twilio/twilio.go new file mode 100644 index 000000000..63489508a --- /dev/null +++ b/notifications/twilio/twilio.go @@ -0,0 +1,37 @@ +package twilio + +import ( + "github.com/pkg/errors" + "github.com/twilio/twilio-go" + twilioservice "github.com/twilio/twilio-go" + openapi "github.com/twilio/twilio-go/rest/api/v2010" +) + +// Twilio is an instance of the Twilio notification service. +type Twilio struct { + fromPhoneNumber string + client *twilio.RestClient +} + +// New will create a new instance of the Twilio service given the credentials. +func New(fromPhoneNumber, accountSid, accessToken string) (*Twilio, error) { + client := twilioservice.NewRestClientWithParams(twilioservice.RestClientParams{ + Username: accountSid, + Password: accessToken, + }) + return &Twilio{fromPhoneNumber: fromPhoneNumber, client: client}, nil +} + +func (t *Twilio) Send(content, to string) error { + params := &openapi.CreateMessageParams{} + params.SetTo(to) + params.SetFrom(t.fromPhoneNumber) + params.SetBody(content) + + _, err := t.client.ApiV2010.CreateMessage(params) + if err != nil { + return errors.Wrap(err, "error sending twilio message") + } + + return nil +} diff --git a/router/router.go b/router/router.go index 14665f54e..16cbf45e2 100644 --- a/router/router.go +++ b/router/router.go @@ -79,6 +79,9 @@ func Start() error { // return followers http.HandleFunc("/api/followers", controllers.GetFollowers) + // Register for notifications + http.HandleFunc("/api/notifications/register", middleware.RequireUserAccessToken(controllers.RegisterForLiveNotifications)) + // Authenticated admin requests // Current inbound broadcaster @@ -302,6 +305,11 @@ func Start() error { // Return federated activities http.HandleFunc("/api/admin/federation/actions", middleware.RequireAdminAuth(admin.GetFederatedActions)) + // Configure outbound notification channels. + http.HandleFunc("/api/admin/config/notifications/discord", middleware.RequireAdminAuth(admin.SetDiscordNotificationConfiguration)) + http.HandleFunc("/api/admin/config/notifications/twilio", middleware.RequireAdminAuth(admin.SetTwilioNotificationConfiguration)) + http.HandleFunc("/api/admin/config/notifications/browser", middleware.RequireAdminAuth(admin.SetBrowserNotificationConfiguration)) + // ActivityPub has its own router activitypub.Start(data.GetDatastore()) diff --git a/webroot/img/browser-push-notifications-settings.png b/webroot/img/browser-push-notifications-settings.png Binary files differnew file mode 100644 index 000000000..c0d48d529 --- /dev/null +++ b/webroot/img/browser-push-notifications-settings.png diff --git a/webroot/img/notification-bell.svg b/webroot/img/notification-bell.svg new file mode 100644 index 000000000..ba8ba6a5f --- /dev/null +++ b/webroot/img/notification-bell.svg @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" version="1.1" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path xmlns="http://www.w3.org/2000/svg" d="m450.201 407.453c-1.505-.977-12.832-8.912-24.174-32.917-20.829-44.082-25.201-106.18-25.201-150.511 0-.193-.004-.384-.011-.576-.227-58.589-35.31-109.095-85.514-131.756v-34.657c0-31.45-25.544-57.036-56.942-57.036h-4.719c-31.398 0-56.942 25.586-56.942 57.036v34.655c-50.372 22.734-85.525 73.498-85.525 132.334 0 44.331-4.372 106.428-25.201 150.511-11.341 24.004-22.668 31.939-24.174 32.917-6.342 2.935-9.469 9.715-8.01 16.586 1.473 6.939 7.959 11.723 15.042 11.723h109.947c.614 42.141 35.008 76.238 77.223 76.238s76.609-34.097 77.223-76.238h109.947c7.082 0 13.569-4.784 15.042-11.723 1.457-6.871-1.669-13.652-8.011-16.586zm-223.502-350.417c0-14.881 12.086-26.987 26.942-26.987h4.719c14.856 0 26.942 12.106 26.942 26.987v24.917c-9.468-1.957-19.269-2.987-29.306-2.987-10.034 0-19.832 1.029-29.296 2.984v-24.914zm29.301 424.915c-25.673 0-46.614-20.617-47.223-46.188h94.445c-.608 25.57-21.549 46.188-47.222 46.188zm60.4-76.239c-.003 0-213.385 0-213.385 0 2.595-4.044 5.236-8.623 7.861-13.798 20.104-39.643 30.298-96.129 30.298-167.889 0-63.417 51.509-115.01 114.821-115.01s114.821 51.593 114.821 115.06c0 .185.003.369.01.553.057 71.472 10.25 127.755 30.298 167.286 2.625 5.176 5.267 9.754 7.861 13.798z" fill="#ffffff" data-original="#000000" class=""/></g></svg> diff --git a/webroot/js/app.js b/webroot/js/app.js index 4ad2dacc8..bbe267a6d 100644 --- a/webroot/js/app.js +++ b/webroot/js/app.js @@ -23,6 +23,8 @@ import FediverseFollowModal, { FediverseFollowButton, } from './components/fediverse-follow-modal.js'; +import { NotifyButton, NotifyModal } from './components/notification.js'; + import { addNewlines, checkUrlPathForDisplay, @@ -59,6 +61,7 @@ import { WIDTH_SINGLE_COL, } from './utils/constants.js'; import { checkIsModerator } from './utils/chat.js'; + import TabBar from './components/tab-bar.js'; export default class App extends Component { @@ -136,6 +139,8 @@ export default class App extends Component { this.displayFediverseFollowModal = this.displayFediverseFollowModal.bind(this); this.closeFediverseFollowModal = this.closeFediverseFollowModal.bind(this); + this.displayNotificationModal = this.displayNotificationModal.bind(this); + this.closeNotificationModal = this.closeNotificationModal.bind(this); // player events this.handlePlayerReady = this.handlePlayerReady.bind(this); @@ -176,6 +181,8 @@ export default class App extends Component { }); this.player.init(); + this.registerServiceWorker(); + // check routing this.getRoute(); } @@ -245,7 +252,7 @@ export default class App extends Component { } setConfigData(data = {}) { - const { name, summary, chatDisabled } = data; + const { name, summary, chatDisabled, notifications } = data; window.document.title = name; // If this is the first time setting the config @@ -258,6 +265,7 @@ export default class App extends Component { this.setState({ canChat: !chatDisabled, + notifications, configData: { ...data, summary: summary && addNewlines(summary), @@ -574,6 +582,23 @@ export default class App extends Component { this.setState({ fediverseModalData: null }); } + displayNotificationModal(data) { + this.setState({ notificationModalData: data }); + } + closeNotificationModal() { + this.setState({ notificationModalData: null }); + } + + async registerServiceWorker() { + try { + const reg = await navigator.serviceWorker.register('/serviceWorker.js', { + scope: '/', + }); + } catch (err) { + console.error('Owncast service worker registration failed!', err); + } + } + handleWebsocketMessage(e) { if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) { // User has been actively disabled on the backend. Turn off chat for them. @@ -662,6 +687,7 @@ export default class App extends Component { render(props, state) { const { + accessToken, chatInputEnabled, configData, displayChatPanel, @@ -682,6 +708,8 @@ export default class App extends Component { windowWidth, fediverseModalData, externalActionModalData, + notificationModalData, + notifications, lastDisconnectTime, section, sectionId, @@ -747,6 +775,11 @@ export default class App extends Component { : html` <${VideoPoster} offlineImage=${logo} active=${streamOnline} /> `; // modal buttons + const notificationsButton = + notifications && + ((notifications.browser.enabled && !!window.chrome) || + notifications.textMessages.enabled) && + html`<${NotifyButton} onClick=${this.displayNotificationModal} />`; const externalActionButtons = html`<div id="external-actions-container" class="flex flex-row flex-wrap justify-end" @@ -768,6 +801,7 @@ export default class App extends Component { federationInfo=${federation} serverName=${name} />`} + ${notificationsButton} </div>`; // modal component @@ -794,6 +828,19 @@ export default class App extends Component { /> `; + const notificationModal = + notificationModalData && + html` <${ExternalActionModal} + onClose=${this.closeNotificationModal} + action=${notificationModalData} + useIframe=${false} + customContent=${html`<${NotifyModal} + notifications=${notifications} + streamName=${name} + accessToken=${accessToken} + />`} + />`; + const chat = this.state.websocket ? html` <${Chat} @@ -801,7 +848,7 @@ export default class App extends Component { username=${username} chatInputEnabled=${chatInputEnabled && !chatDisabled} instanceTitle=${name} - accessToken=${this.state.accessToken} + accessToken=${accessToken} inputMaxBytes=${maxSocketPayloadSize - EST_SOCKET_PAYLOAD_BUFFER || CHAT_MAX_MESSAGE_LENGTH} /> @@ -968,6 +1015,7 @@ export default class App extends Component { </footer> ${chat} ${externalActionModal} ${fediverseFollowModal} + ${notificationModal} </div> `; } diff --git a/webroot/js/components/notification.js b/webroot/js/components/notification.js new file mode 100644 index 000000000..50a708fdd --- /dev/null +++ b/webroot/js/components/notification.js @@ -0,0 +1,211 @@ +import { h, Component } from '/js/web_modules/preact.js'; +import { useState } from '/js/web_modules/preact/hooks.js'; + +import htm from '/js/web_modules/htm.js'; +import { ExternalActionButton } from './external-action-modal.js'; +import { + registerWebPushNotifications, + isPushNotificationSupported, +} from '../notification/registerWeb.js'; +import { URL_REGISTER_NOTIFICATION } from '../utils/constants.js'; + +const html = htm.bind(h); + +export function NotifyModal({ notifications, streamName, accessToken }) { + const [error, setError] = useState(null); + const [loaderStyle, setLoaderStyle] = useState('none'); + const [phoneNotificationsButtonEnabled, setPhoneNotificationsButtonEnabled] = + useState(false); + const [phoneNumber, setPhoneNumber] = useState(null); + const phoneNotificationButtonState = phoneNotificationsButtonEnabled + ? '' + : 'cursor-not-allowed opacity-50'; + + const [browserRegistrationComplete, setBrowserRegistrationComplete] = + useState(false); + + const browserNotificationsButtonState = + Notification.permission === 'default' + ? '' + : 'cursor-not-allowed opacity-50'; + + const { browser, textMessages } = notifications; + const { publicKey } = browser; + + let browserPushEnabled = browser.enabled; + let textMessagesEnabled = textMessages.enabled; + + // Browser push notifications are only supported on Chrome currently. + // Also make sure the browser supports them. + if (!window.chrome || !isPushNotificationSupported()) { + browserPushEnabled = false; + } + + async function saveNotificationRegistration(channel, destination) { + const options = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ channel: channel, destination: destination }), + }; + + try { + await fetch( + URL_REGISTER_NOTIFICATION + `?accessToken=${accessToken}`, + options + ); + } catch (e) { + console.error(e); + } + } + + async function registerForBrowserPushButtonPressed() { + // If it's already denied or granted, don't do anything. + if (Notification.permission !== 'default') { + return; + } + + try { + const subscription = await registerWebPushNotifications(publicKey); + saveNotificationRegistration('BROWSER_PUSH_NOTIFICATION', subscription); + setBrowserRegistrationComplete(true); + setError(null); + } catch (e) { + setError( + `Error registering for notifications: ${e.message}. Check your browser notification permissions and try again.` + ); + } + } + + function onPhoneInput(e) { + const { value } = e.target; + + // Since phone number validation is difficult let's just make sure + // it has a reasonable number of digits and doesn't include letters. + + // Strip non-numeric characters. + const validated = value.replace(/[^\d]/g, ''); + + let valid = false; + if (validated.length >= 11 && validated.length <= 16) { + valid = true; + } + + setPhoneNumber(validated); + setPhoneNotificationsButtonEnabled(valid); + } + + function getBrowserPushButtonText() { + if (browserRegistrationComplete) { + return 'Done!'; + } + + let pushNotificationButtonText = 'Notify Me!'; + if (Notification.permission === 'granted') { + pushNotificationButtonText = 'Enabled'; + } else if (Notification.permission === 'denied') { + pushNotificationButtonText = 'Browser pushes denied'; + } + return pushNotificationButtonText; + } + + var gridColumns = 2; + if (browserPushEnabled && !textMessagesEnabled) { + gridColumns = 1; + } else if (!browserPushEnabled && textMessagesEnabled) { + gridColumns = 1; + } + + const pushNotificationButtonText = getBrowserPushButtonText(); + + return html` + <div class="bg-gray-100 bg-center bg-no-repeat p-4"> + <p class="text-gray-700 text-md font-semibold mb-2"> + Never miss a stream! Get notified when ${streamName} goes live. + </p> + + <div class="grid grid-cols-${gridColumns} gap-6"> + <div style=${{ display: browserPushEnabled ? 'block' : 'none' }}> + <h2 class="text-indigo-600 text-3xl font-semibold">Browser</h2> + <p>Get notified inside your browser when ${streamName} goes live.</p> + <p class="mt-4"> + Make sure you change the setting from ${' '} + <em>until I close this site</em> or you won't receive future + notifications. + </p> + <img + class="mt-4" + src="/img/browser-push-notifications-settings.png" + /> + <div + style=${error ? 'display: block' : 'display: none'} + class="bg-red-100 border border-red-400 text-red-700 mt-5 px-4 py-3 rounded relative" + role="alert" + > + ${error} + </div> + <button + class="rounded-sm flex flex-row justify-center items-center overflow-hidden mt-5 px-3 py-1 text-base text-white bg-gray-800 ${browserNotificationsButtonState}" + onClick=${registerForBrowserPushButtonPressed} + > + ${pushNotificationButtonText} + </button> + </div> + <div style=${{ display: textMessagesEnabled ? 'block' : 'none' }}> + <h2 class="text-indigo-600 text-3xl font-semibold">Phone</h2> + <p> + No apps or 3rd party services required. Get notified directly when + ${''} ${streamName} goes live. + </p> + <p class="mt-4"> + Respond to notifications with <strong>stop</strong> to stop + receiving them. + </p> + <input + class="border bg-white rounded w-full py-2 px-3 mb-2 mt-10 text-indigo-700 leading-tight focus:outline-none focus:shadow-outline" + type="tel" + maxlength="16" + value=${phoneNumber} + onInput=${onPhoneInput} + placeholder="Your phone number 14023133798" + /> + <p class="text-gray-600 text-xs italic"> + Provide your phone number with the international country code. + </p> + + <button + class="rounded-sm flex flex-row justify-center items-center overflow-hidden mt-5 px-3 py-1 text-base text-white bg-gray-800 ${phoneNotificationButtonState}" + onClick=${registerForBrowserPushButtonPressed} + > + Notify Me! + </button> + </div> + </div> + + <div + id="follow-loading-spinner-container" + style="display: ${loaderStyle}" + > + <img id="follow-loading-spinner" src="/img/loading.gif" /> + <p class="text-gray-700 text-lg">Contacting your server.</p> + <p class="text-gray-600 text-lg">Please wait...</p> + </div> + </div> + `; +} + +export function NotifyButton({ serverName, federationInfo, onClick }) { + const notifyAction = { + // color: 'rgba(28, 26, 59, 1)', + description: `Notify`, + icon: '/img/notification-bell.svg', + openExternally: false, + }; + + return html` + <span id="fediverse-follow-button-container"> + <${ExternalActionButton} onClick=${onClick} action=${notifyAction} /> + </span> + `; +} diff --git a/webroot/js/notification/registerWeb.js b/webroot/js/notification/registerWeb.js new file mode 100644 index 000000000..32340f9ae --- /dev/null +++ b/webroot/js/notification/registerWeb.js @@ -0,0 +1,26 @@ +export async function registerWebPushNotifications(vapidPublicKey) { + const registration = await navigator.serviceWorker.ready; + let subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + }); + } + + return JSON.stringify(subscription); +} + +export function isPushNotificationSupported() { + return 'serviceWorker' in navigator && 'PushManager' in window; +} + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + const rawData = atob(base64); + return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); +} diff --git a/webroot/js/utils/constants.js b/webroot/js/utils/constants.js index adb59e04e..11189f412 100644 --- a/webroot/js/utils/constants.js +++ b/webroot/js/utils/constants.js @@ -18,6 +18,8 @@ export const URL_WEBSOCKET = `${ export const URL_CHAT_REGISTRATION = `/api/chat/register`; export const URL_FOLLOWERS = `/api/followers`; +export const URL_REGISTER_NOTIFICATION = `/api/notifications/register`; + export const TIMER_STATUS_UPDATE = 5000; // ms export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins export const TIMER_STREAM_DURATION_COUNTER = 1000; diff --git a/webroot/js/web_modules/htm.js b/webroot/js/web_modules/htm.js index e986361d6..4c86298da 100644 --- a/webroot/js/web_modules/htm.js +++ b/webroot/js/web_modules/htm.js @@ -1,3 +1,3 @@ var n=function(t,s,r,e){var u;s[0]=0;for(var h=1;h<s.length;h++){var p=s[h++],a=s[h]?(s[0]|=p?1:2,r[s[h++]]):s[++h];3===p?e[0]=a:4===p?e[1]=Object.assign(e[1]||{},a):5===p?(e[1]=e[1]||{})[s[++h]]=a:6===p?e[1][s[++h]]+=a+"":p?(u=t.apply(a,n(t,a,r,["",null])),e.push(u),a[0]?s[0]|=2:(s[h-2]=0,s[h]=u)):e.push(a);}return e},t=new Map;function htm_module(s){var r=t.get(this);return r||(r=new Map,t.set(this,r)),(r=n(this,r.get(s)||(r.set(s,r=function(n){for(var t,s,r=1,e="",u="",h=[0],p=function(n){1===r&&(n||(e=e.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?h.push(0,n,e):3===r&&(n||e)?(h.push(3,n,e),r=2):2===r&&"..."===e&&n?h.push(4,n,0):2===r&&e&&!n?h.push(5,0,!0,e):r>=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e="";},a=0;a<n.length;a++){a&&(1===r&&p(),p(a));for(var l=0;l<n[a].length;l++)t=n[a][l],1===r?"<"===t?(p(),h=[h],r=3):e+=t:4===r?"--"===e&&">"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0]);}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]} -export { htm_module as default }; +export default htm_module; diff --git a/webroot/js/web_modules/import-map.json b/webroot/js/web_modules/import-map.json index f646538a0..12013ed2e 100644 --- a/webroot/js/web_modules/import-map.json +++ b/webroot/js/web_modules/import-map.json @@ -6,6 +6,7 @@ "mark.js/dist/mark.es6.min.js": "./markjs/dist/mark.es6.min.js", "micromodal/dist/micromodal.min.js": "./micromodal/dist/micromodal.min.js", "preact": "./preact.js", + "preact/hooks": "./preact/hooks.js", "tailwindcss/dist/tailwind.min.css": "./tailwindcss/dist/tailwind.min.css", "video.js/dist/video-js.min.css": "./videojs/dist/video-js.min.css", "video.js/dist/video.min.js": "./videojs/dist/video.min.js" diff --git a/webroot/js/web_modules/markjs/dist/mark.es6.min.js b/webroot/js/web_modules/markjs/dist/mark.es6.min.js index 71d25821c..fd4c676ff 100644 --- a/webroot/js/web_modules/markjs/dist/mark.es6.min.js +++ b/webroot/js/web_modules/markjs/dist/mark.es6.min.js @@ -7,7 +7,7 @@ var mark_es6_min = createCommonjsModule(function (module, exports) { * Copyright (c) 2014–2018, Julian Kühnel * Released under the MIT license https://git.io/vwTVl *****************************************************/ -!function(e,t){module.exports=t();}(commonjsGlobal,function(){class e{constructor(e,t=!0,s=[],r=5e3){this.ctx=e,this.iframes=t,this.exclude=s,this.iframesTimeout=r;}static matches(e,t){const s="string"==typeof t?[t]:t,r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.oMatchesSelector||e.webkitMatchesSelector;if(r){let t=!1;return s.every(s=>!r.call(e,s)||(t=!0,!1)),t}return !1}getContexts(){let t=[];return (void 0!==this.ctx&&this.ctx?NodeList.prototype.isPrototypeOf(this.ctx)?Array.prototype.slice.call(this.ctx):Array.isArray(this.ctx)?this.ctx:"string"==typeof this.ctx?Array.prototype.slice.call(document.querySelectorAll(this.ctx)):[this.ctx]:[]).forEach(e=>{const s=t.filter(t=>t.contains(e)).length>0;-1!==t.indexOf(e)||s||t.push(e);}),t}getIframeContents(e,t,s=(()=>{})){let r;try{const t=e.contentWindow;if(r=t.document,!t||!r)throw new Error("iframe inaccessible")}catch(e){s();}r&&t(r);}isIframeBlank(e){const t="about:blank",s=e.getAttribute("src").trim(),r=e.contentWindow.location.href;return r===t&&s!==t&&s}observeIframeLoad(e,t,s){let r=!1,i=null;const o=()=>{if(!r){r=!0,clearTimeout(i);try{this.isIframeBlank(e)||(e.removeEventListener("load",o),this.getIframeContents(e,t,s));}catch(e){s();}}};e.addEventListener("load",o),i=setTimeout(o,this.iframesTimeout);}onIframeReady(e,t,s){try{"complete"===e.contentWindow.document.readyState?this.isIframeBlank(e)?this.observeIframeLoad(e,t,s):this.getIframeContents(e,t,s):this.observeIframeLoad(e,t,s);}catch(e){s();}}waitForIframes(e,t){let s=0;this.forEachIframe(e,()=>!0,e=>{s++,this.waitForIframes(e.querySelector("html"),()=>{--s||t();});},e=>{e||t();});}forEachIframe(t,s,r,i=(()=>{})){let o=t.querySelectorAll("iframe"),n=o.length,a=0;o=Array.prototype.slice.call(o);const c=()=>{--n<=0&&i(a);};n||c(),o.forEach(t=>{e.matches(t,this.exclude)?c():this.onIframeReady(t,e=>{s(t)&&(a++,r(e)),c();},c);});}createIterator(e,t,s){return document.createNodeIterator(e,t,s,!1)}createInstanceOnIframe(t){return new e(t.querySelector("html"),this.iframes)}compareNodeIframe(e,t,s){const r=e.compareDocumentPosition(s),i=Node.DOCUMENT_POSITION_PRECEDING;if(r&i){if(null===t)return !0;if(t.compareDocumentPosition(s)&Node.DOCUMENT_POSITION_FOLLOWING)return !0}return !1}getIteratorNode(e){const t=e.previousNode();return {prevNode:t,node:null===t?e.nextNode():e.nextNode()&&e.nextNode()}}checkIframeFilter(e,t,s,r){let i=!1,o=!1;return r.forEach((e,t)=>{e.val===s&&(i=t,o=e.handled);}),this.compareNodeIframe(e,t,s)?(!1!==i||o?!1===i||o||(r[i].handled=!0):r.push({val:s,handled:!0}),!0):(!1===i&&r.push({val:s,handled:!1}),!1)}handleOpenIframes(e,t,s,r){e.forEach(e=>{e.handled||this.getIframeContents(e.val,e=>{this.createInstanceOnIframe(e).forEachNode(t,s,r);});});}iterateThroughNodes(e,t,s,r,i){const o=this.createIterator(t,e,r);let n,a,c=[],h=[],l=()=>((({prevNode:a,node:n}=this.getIteratorNode(o))),n);for(;l();)this.iframes&&this.forEachIframe(t,e=>this.checkIframeFilter(n,a,e,c),t=>{this.createInstanceOnIframe(t).forEachNode(e,e=>h.push(e),r);}),h.push(n);h.forEach(e=>{s(e);}),this.iframes&&this.handleOpenIframes(c,e,s,r),i();}forEachNode(e,t,s,r=(()=>{})){const i=this.getContexts();let o=i.length;o||r(),i.forEach(i=>{const n=()=>{this.iterateThroughNodes(e,i,t,s,()=>{--o<=0&&r();});};this.iframes?this.waitForIframes(i,n):n();});}}class t{constructor(e){this.ctx=e,this.ie=!1;const t=window.navigator.userAgent;(t.indexOf("MSIE")>-1||t.indexOf("Trident")>-1)&&(this.ie=!0);}set opt(e){this._opt=Object.assign({},{element:"",className:"",exclude:[],iframes:!1,iframesTimeout:5e3,separateWordSearch:!0,diacritics:!0,synonyms:{},accuracy:"partially",acrossElements:!1,caseSensitive:!1,ignoreJoiners:!1,ignoreGroups:0,ignorePunctuation:[],wildcards:"disabled",each:()=>{},noMatch:()=>{},filter:()=>!0,done:()=>{},debug:!1,log:window.console},e);}get opt(){return this._opt}get iterator(){return new e(this.ctx,this.opt.iframes,this.opt.exclude,this.opt.iframesTimeout)}log(e,t="debug"){const s=this.opt.log;this.opt.debug&&"object"==typeof s&&"function"==typeof s[t]&&s[t](`mark.js: ${e}`);}escapeStr(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}createRegExp(e){return "disabled"!==this.opt.wildcards&&(e=this.setupWildcardsRegExp(e)),e=this.escapeStr(e),Object.keys(this.opt.synonyms).length&&(e=this.createSynonymsRegExp(e)),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),this.opt.diacritics&&(e=this.createDiacriticsRegExp(e)),e=this.createMergedBlanksRegExp(e),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.createJoinersRegExp(e)),"disabled"!==this.opt.wildcards&&(e=this.createWildcardsRegExp(e)),e=this.createAccuracyRegExp(e)}createSynonymsRegExp(e){const t=this.opt.synonyms,s=this.opt.caseSensitive?"":"i",r=this.opt.ignoreJoiners||this.opt.ignorePunctuation.length?"\0":"";for(let i in t)if(t.hasOwnProperty(i)){const o=t[i],n="disabled"!==this.opt.wildcards?this.setupWildcardsRegExp(i):this.escapeStr(i),a="disabled"!==this.opt.wildcards?this.setupWildcardsRegExp(o):this.escapeStr(o);""!==n&&""!==a&&(e=e.replace(new RegExp(`(${this.escapeStr(n)}|${this.escapeStr(a)})`,`gm${s}`),r+`(${this.processSynomyms(n)}|`+`${this.processSynomyms(a)})`+r));}return e}processSynomyms(e){return (this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),e}setupWildcardsRegExp(e){return (e=e.replace(/(?:\\)*\?/g,e=>"\\"===e.charAt(0)?"?":"")).replace(/(?:\\)*\*/g,e=>"\\"===e.charAt(0)?"*":"")}createWildcardsRegExp(e){let t="withSpaces"===this.opt.wildcards;return e.replace(/\u0001/g,t?"[\\S\\s]?":"\\S?").replace(/\u0002/g,t?"[\\S\\s]*?":"\\S*")}setupIgnoreJoinersRegExp(e){return e.replace(/[^(|)\\]/g,(e,t,s)=>{let r=s.charAt(t+1);return /[(|)\\]/.test(r)||""===r?e:e+"\0"})}createJoinersRegExp(e){let t=[];const s=this.opt.ignorePunctuation;return Array.isArray(s)&&s.length&&t.push(this.escapeStr(s.join(""))),this.opt.ignoreJoiners&&t.push("\\u00ad\\u200b\\u200c\\u200d"),t.length?e.split(/\u0000+/).join(`[${t.join("")}]*`):e}createDiacriticsRegExp(e){const t=this.opt.caseSensitive?"":"i",s=this.opt.caseSensitive?["aàáảãạăằắẳẵặâầấẩẫậäåāą","AÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćč","CÇĆČ","dđď","DĐĎ","eèéẻẽẹêềếểễệëěēę","EÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïī","IÌÍỈĨỊÎÏĪ","lł","LŁ","nñňń","NÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøō","OÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rř","RŘ","sšśșş","SŠŚȘŞ","tťțţ","TŤȚŢ","uùúủũụưừứửữựûüůū","UÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿ","YÝỲỶỸỴŸ","zžżź","ZŽŻŹ"]:["aàáảãạăằắẳẵặâầấẩẫậäåāąAÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćčCÇĆČ","dđďDĐĎ","eèéẻẽẹêềếểễệëěēęEÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïīIÌÍỈĨỊÎÏĪ","lłLŁ","nñňńNÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøōOÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rřRŘ","sšśșşSŠŚȘŞ","tťțţTŤȚŢ","uùúủũụưừứửữựûüůūUÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿYÝỲỶỸỴŸ","zžżźZŽŻŹ"];let r=[];return e.split("").forEach(i=>{s.every(s=>{if(-1!==s.indexOf(i)){if(r.indexOf(s)>-1)return !1;e=e.replace(new RegExp(`[${s}]`,`gm${t}`),`[${s}]`),r.push(s);}return !0});}),e}createMergedBlanksRegExp(e){return e.replace(/[\s]+/gim,"[\\s]+")}createAccuracyRegExp(e){const t="!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~¡¿";let s=this.opt.accuracy,r="string"==typeof s?s:s.value,i="string"==typeof s?[]:s.limiters,o="";switch(i.forEach(e=>{o+=`|${this.escapeStr(e)}`;}),r){case"partially":default:return `()(${e})`;case"complementary":return `()([^${o="\\s"+(o||this.escapeStr(t))}]*${e}[^${o}]*)`;case"exactly":return `(^|\\s${o})(${e})(?=$|\\s${o})`}}getSeparatedKeywords(e){let t=[];return e.forEach(e=>{this.opt.separateWordSearch?e.split(" ").forEach(e=>{e.trim()&&-1===t.indexOf(e)&&t.push(e);}):e.trim()&&-1===t.indexOf(e)&&t.push(e);}),{keywords:t.sort((e,t)=>t.length-e.length),length:t.length}}isNumeric(e){return Number(parseFloat(e))==e}checkRanges(e){if(!Array.isArray(e)||"[object Object]"!==Object.prototype.toString.call(e[0]))return this.log("markRanges() will only accept an array of objects"),this.opt.noMatch(e),[];const t=[];let s=0;return e.sort((e,t)=>e.start-t.start).forEach(e=>{let{start:r,end:i,valid:o}=this.callNoMatchOnInvalidRanges(e,s);o&&(e.start=r,e.length=i-r,t.push(e),s=i);}),t}callNoMatchOnInvalidRanges(e,t){let s,r,i=!1;return e&&void 0!==e.start?(r=(s=parseInt(e.start,10))+parseInt(e.length,10),this.isNumeric(e.start)&&this.isNumeric(e.length)&&r-t>0&&r-s>0?i=!0:(this.log("Ignoring invalid or overlapping range: "+`${JSON.stringify(e)}`),this.opt.noMatch(e))):(this.log(`Ignoring invalid range: ${JSON.stringify(e)}`),this.opt.noMatch(e)),{start:s,end:r,valid:i}}checkWhitespaceRanges(e,t,s){let r,i=!0,o=s.length,n=t-o,a=parseInt(e.start,10)-n;return (r=(a=a>o?o:a)+parseInt(e.length,10))>o&&(r=o,this.log(`End range automatically set to the max value of ${o}`)),a<0||r-a<0||a>o||r>o?(i=!1,this.log(`Invalid range: ${JSON.stringify(e)}`),this.opt.noMatch(e)):""===s.substring(a,r).replace(/\s+/g,"")&&(i=!1,this.log("Skipping whitespace only range: "+JSON.stringify(e)),this.opt.noMatch(e)),{start:a,end:r,valid:i}}getTextNodes(e){let t="",s=[];this.iterator.forEachNode(NodeFilter.SHOW_TEXT,e=>{s.push({start:t.length,end:(t+=e.textContent).length,node:e});},e=>this.matchesExclude(e.parentNode)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT,()=>{e({value:t,nodes:s});});}matchesExclude(t){return e.matches(t,this.opt.exclude.concat(["script","style","title","head","html"]))}wrapRangeInTextNode(e,t,s){const r=this.opt.element?this.opt.element:"mark",i=e.splitText(t),o=i.splitText(s-t);let n=document.createElement(r);return n.setAttribute("data-markjs","true"),this.opt.className&&n.setAttribute("class",this.opt.className),n.textContent=i.textContent,i.parentNode.replaceChild(n,i),o}wrapRangeInMappedTextNode(e,t,s,r,i){e.nodes.every((o,n)=>{const a=e.nodes[n+1];if(void 0===a||a.start>t){if(!r(o.node))return !1;const a=t-o.start,c=(s>o.end?o.end:s)-o.start,h=e.value.substr(0,o.start),l=e.value.substr(c+o.start);if(o.node=this.wrapRangeInTextNode(o.node,a,c),e.value=h+l,e.nodes.forEach((t,s)=>{s>=n&&(e.nodes[s].start>0&&s!==n&&(e.nodes[s].start-=c),e.nodes[s].end-=c);}),s-=c,i(o.node.previousSibling,o.start),!(s>o.end))return !1;t=o.end;}return !0});}wrapMatches(e,t,s,r,i){const o=0===t?0:t+1;this.getTextNodes(t=>{t.nodes.forEach(t=>{let i;for(t=t.node;null!==(i=e.exec(t.textContent))&&""!==i[o];){if(!s(i[o],t))continue;let n=i.index;if(0!==o)for(let e=1;e<o;e++)n+=i[e].length;t=this.wrapRangeInTextNode(t,n,n+i[o].length),r(t.previousSibling),e.lastIndex=0;}}),i();});}wrapMatchesAcrossElements(e,t,s,r,i){const o=0===t?0:t+1;this.getTextNodes(t=>{let n;for(;null!==(n=e.exec(t.value))&&""!==n[o];){let i=n.index;if(0!==o)for(let e=1;e<o;e++)i+=n[e].length;const a=i+n[o].length;this.wrapRangeInMappedTextNode(t,i,a,e=>s(n[o],e),(t,s)=>{e.lastIndex=s,r(t);});}i();});}wrapRangeFromIndex(e,t,s,r){this.getTextNodes(i=>{const o=i.value.length;e.forEach((e,r)=>{let{start:n,end:a,valid:c}=this.checkWhitespaceRanges(e,o,i.value);c&&this.wrapRangeInMappedTextNode(i,n,a,s=>t(s,e,i.value.substring(n,a),r),t=>{s(t,e);});}),r();});}unwrapMatches(e){const t=e.parentNode;let s=document.createDocumentFragment();for(;e.firstChild;)s.appendChild(e.removeChild(e.firstChild));t.replaceChild(s,e),this.ie?this.normalizeTextNode(t):t.normalize();}normalizeTextNode(e){if(e){if(3===e.nodeType)for(;e.nextSibling&&3===e.nextSibling.nodeType;)e.nodeValue+=e.nextSibling.nodeValue,e.parentNode.removeChild(e.nextSibling);else this.normalizeTextNode(e.firstChild);this.normalizeTextNode(e.nextSibling);}}markRegExp(e,t){this.opt=t,this.log(`Searching with expression "${e}"`);let s=0,r="wrapMatches";const i=e=>{s++,this.opt.each(e);};this.opt.acrossElements&&(r="wrapMatchesAcrossElements"),this[r](e,this.opt.ignoreGroups,(e,t)=>this.opt.filter(t,e,s),i,()=>{0===s&&this.opt.noMatch(e),this.opt.done(s);});}mark(e,t){this.opt=t;let s=0,r="wrapMatches";const{keywords:i,length:o}=this.getSeparatedKeywords("string"==typeof e?[e]:e),n=this.opt.caseSensitive?"":"i",a=e=>{let t=new RegExp(this.createRegExp(e),`gm${n}`),c=0;this.log(`Searching with expression "${t}"`),this[r](t,1,(t,r)=>this.opt.filter(r,e,s,c),e=>{c++,s++,this.opt.each(e);},()=>{0===c&&this.opt.noMatch(e),i[o-1]===e?this.opt.done(s):a(i[i.indexOf(e)+1]);});};this.opt.acrossElements&&(r="wrapMatchesAcrossElements"),0===o?this.opt.done(s):a(i[0]);}markRanges(e,t){this.opt=t;let s=0,r=this.checkRanges(e);r&&r.length?(this.log("Starting to mark with the following ranges: "+JSON.stringify(r)),this.wrapRangeFromIndex(r,(e,t,s,r)=>this.opt.filter(e,t,s,r),(e,t)=>{s++,this.opt.each(e,t);},()=>{this.opt.done(s);})):this.opt.done(s);}unmark(t){this.opt=t;let s=this.opt.element?this.opt.element:"*";s+="[data-markjs]",this.opt.className&&(s+=`.${this.opt.className}`),this.log(`Removal selector "${s}"`),this.iterator.forEachNode(NodeFilter.SHOW_ELEMENT,e=>{this.unwrapMatches(e);},t=>{const r=e.matches(t,s),i=this.matchesExclude(t);return !r||i?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT},this.opt.done);}}return function(e){const s=new t(e);return this.mark=((e,t)=>(s.mark(e,t),this)),this.markRegExp=((e,t)=>(s.markRegExp(e,t),this)),this.markRanges=((e,t)=>(s.markRanges(e,t),this)),this.unmark=(e=>(s.unmark(e),this)),this}}); +!function(e,t){module.exports=t();}(commonjsGlobal,function(){class e{constructor(e,t=!0,s=[],r=5e3){this.ctx=e,this.iframes=t,this.exclude=s,this.iframesTimeout=r;}static matches(e,t){const s="string"==typeof t?[t]:t,r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.oMatchesSelector||e.webkitMatchesSelector;if(r){let t=!1;return s.every(s=>!r.call(e,s)||(t=!0,!1)),t}return !1}getContexts(){let t=[];return (void 0!==this.ctx&&this.ctx?NodeList.prototype.isPrototypeOf(this.ctx)?Array.prototype.slice.call(this.ctx):Array.isArray(this.ctx)?this.ctx:"string"==typeof this.ctx?Array.prototype.slice.call(document.querySelectorAll(this.ctx)):[this.ctx]:[]).forEach(e=>{const s=t.filter(t=>t.contains(e)).length>0;-1!==t.indexOf(e)||s||t.push(e);}),t}getIframeContents(e,t,s=(()=>{})){let r;try{const t=e.contentWindow;if(r=t.document,!t||!r)throw new Error("iframe inaccessible")}catch(e){s();}r&&t(r);}isIframeBlank(e){const t="about:blank",s=e.getAttribute("src").trim(),r=e.contentWindow.location.href;return r===t&&s!==t&&s}observeIframeLoad(e,t,s){let r=!1,i=null;const o=()=>{if(!r){r=!0,clearTimeout(i);try{this.isIframeBlank(e)||(e.removeEventListener("load",o),this.getIframeContents(e,t,s));}catch(e){s();}}};e.addEventListener("load",o),i=setTimeout(o,this.iframesTimeout);}onIframeReady(e,t,s){try{"complete"===e.contentWindow.document.readyState?this.isIframeBlank(e)?this.observeIframeLoad(e,t,s):this.getIframeContents(e,t,s):this.observeIframeLoad(e,t,s);}catch(e){s();}}waitForIframes(e,t){let s=0;this.forEachIframe(e,()=>!0,e=>{s++,this.waitForIframes(e.querySelector("html"),()=>{--s||t();});},e=>{e||t();});}forEachIframe(t,s,r,i=(()=>{})){let o=t.querySelectorAll("iframe"),n=o.length,a=0;o=Array.prototype.slice.call(o);const c=()=>{--n<=0&&i(a);};n||c(),o.forEach(t=>{e.matches(t,this.exclude)?c():this.onIframeReady(t,e=>{s(t)&&(a++,r(e)),c();},c);});}createIterator(e,t,s){return document.createNodeIterator(e,t,s,!1)}createInstanceOnIframe(t){return new e(t.querySelector("html"),this.iframes)}compareNodeIframe(e,t,s){const r=e.compareDocumentPosition(s),i=Node.DOCUMENT_POSITION_PRECEDING;if(r&i){if(null===t)return !0;if(t.compareDocumentPosition(s)&Node.DOCUMENT_POSITION_FOLLOWING)return !0}return !1}getIteratorNode(e){const t=e.previousNode();return {prevNode:t,node:null===t?e.nextNode():e.nextNode()&&e.nextNode()}}checkIframeFilter(e,t,s,r){let i=!1,o=!1;return r.forEach((e,t)=>{e.val===s&&(i=t,o=e.handled);}),this.compareNodeIframe(e,t,s)?(!1!==i||o?!1===i||o||(r[i].handled=!0):r.push({val:s,handled:!0}),!0):(!1===i&&r.push({val:s,handled:!1}),!1)}handleOpenIframes(e,t,s,r){e.forEach(e=>{e.handled||this.getIframeContents(e.val,e=>{this.createInstanceOnIframe(e).forEachNode(t,s,r);});});}iterateThroughNodes(e,t,s,r,i){const o=this.createIterator(t,e,r);let n,a,c=[],h=[],l=()=>(({prevNode:a,node:n}=this.getIteratorNode(o)),n);for(;l();)this.iframes&&this.forEachIframe(t,e=>this.checkIframeFilter(n,a,e,c),t=>{this.createInstanceOnIframe(t).forEachNode(e,e=>h.push(e),r);}),h.push(n);h.forEach(e=>{s(e);}),this.iframes&&this.handleOpenIframes(c,e,s,r),i();}forEachNode(e,t,s,r=(()=>{})){const i=this.getContexts();let o=i.length;o||r(),i.forEach(i=>{const n=()=>{this.iterateThroughNodes(e,i,t,s,()=>{--o<=0&&r();});};this.iframes?this.waitForIframes(i,n):n();});}}class t{constructor(e){this.ctx=e,this.ie=!1;const t=window.navigator.userAgent;(t.indexOf("MSIE")>-1||t.indexOf("Trident")>-1)&&(this.ie=!0);}set opt(e){this._opt=Object.assign({},{element:"",className:"",exclude:[],iframes:!1,iframesTimeout:5e3,separateWordSearch:!0,diacritics:!0,synonyms:{},accuracy:"partially",acrossElements:!1,caseSensitive:!1,ignoreJoiners:!1,ignoreGroups:0,ignorePunctuation:[],wildcards:"disabled",each:()=>{},noMatch:()=>{},filter:()=>!0,done:()=>{},debug:!1,log:window.console},e);}get opt(){return this._opt}get iterator(){return new e(this.ctx,this.opt.iframes,this.opt.exclude,this.opt.iframesTimeout)}log(e,t="debug"){const s=this.opt.log;this.opt.debug&&"object"==typeof s&&"function"==typeof s[t]&&s[t](`mark.js: ${e}`);}escapeStr(e){return e.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&")}createRegExp(e){return "disabled"!==this.opt.wildcards&&(e=this.setupWildcardsRegExp(e)),e=this.escapeStr(e),Object.keys(this.opt.synonyms).length&&(e=this.createSynonymsRegExp(e)),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),this.opt.diacritics&&(e=this.createDiacriticsRegExp(e)),e=this.createMergedBlanksRegExp(e),(this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.createJoinersRegExp(e)),"disabled"!==this.opt.wildcards&&(e=this.createWildcardsRegExp(e)),e=this.createAccuracyRegExp(e)}createSynonymsRegExp(e){const t=this.opt.synonyms,s=this.opt.caseSensitive?"":"i",r=this.opt.ignoreJoiners||this.opt.ignorePunctuation.length?"\0":"";for(let i in t)if(t.hasOwnProperty(i)){const o=t[i],n="disabled"!==this.opt.wildcards?this.setupWildcardsRegExp(i):this.escapeStr(i),a="disabled"!==this.opt.wildcards?this.setupWildcardsRegExp(o):this.escapeStr(o);""!==n&&""!==a&&(e=e.replace(new RegExp(`(${this.escapeStr(n)}|${this.escapeStr(a)})`,`gm${s}`),r+`(${this.processSynomyms(n)}|`+`${this.processSynomyms(a)})`+r));}return e}processSynomyms(e){return (this.opt.ignoreJoiners||this.opt.ignorePunctuation.length)&&(e=this.setupIgnoreJoinersRegExp(e)),e}setupWildcardsRegExp(e){return (e=e.replace(/(?:\\)*\?/g,e=>"\\"===e.charAt(0)?"?":"")).replace(/(?:\\)*\*/g,e=>"\\"===e.charAt(0)?"*":"")}createWildcardsRegExp(e){let t="withSpaces"===this.opt.wildcards;return e.replace(/\u0001/g,t?"[\\S\\s]?":"\\S?").replace(/\u0002/g,t?"[\\S\\s]*?":"\\S*")}setupIgnoreJoinersRegExp(e){return e.replace(/[^(|)\\]/g,(e,t,s)=>{let r=s.charAt(t+1);return /[(|)\\]/.test(r)||""===r?e:e+"\0"})}createJoinersRegExp(e){let t=[];const s=this.opt.ignorePunctuation;return Array.isArray(s)&&s.length&&t.push(this.escapeStr(s.join(""))),this.opt.ignoreJoiners&&t.push("\\u00ad\\u200b\\u200c\\u200d"),t.length?e.split(/\u0000+/).join(`[${t.join("")}]*`):e}createDiacriticsRegExp(e){const t=this.opt.caseSensitive?"":"i",s=this.opt.caseSensitive?["aàáảãạăằắẳẵặâầấẩẫậäåāą","AÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćč","CÇĆČ","dđď","DĐĎ","eèéẻẽẹêềếểễệëěēę","EÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïī","IÌÍỈĨỊÎÏĪ","lł","LŁ","nñňń","NÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøō","OÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rř","RŘ","sšśșş","SŠŚȘŞ","tťțţ","TŤȚŢ","uùúủũụưừứửữựûüůū","UÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿ","YÝỲỶỸỴŸ","zžżź","ZŽŻŹ"]:["aàáảãạăằắẳẵặâầấẩẫậäåāąAÀÁẢÃẠĂẰẮẲẴẶÂẦẤẨẪẬÄÅĀĄ","cçćčCÇĆČ","dđďDĐĎ","eèéẻẽẹêềếểễệëěēęEÈÉẺẼẸÊỀẾỂỄỆËĚĒĘ","iìíỉĩịîïīIÌÍỈĨỊÎÏĪ","lłLŁ","nñňńNÑŇŃ","oòóỏõọôồốổỗộơởỡớờợöøōOÒÓỎÕỌÔỒỐỔỖỘƠỞỠỚỜỢÖØŌ","rřRŘ","sšśșşSŠŚȘŞ","tťțţTŤȚŢ","uùúủũụưừứửữựûüůūUÙÚỦŨỤƯỪỨỬỮỰÛÜŮŪ","yýỳỷỹỵÿYÝỲỶỸỴŸ","zžżźZŽŻŹ"];let r=[];return e.split("").forEach(i=>{s.every(s=>{if(-1!==s.indexOf(i)){if(r.indexOf(s)>-1)return !1;e=e.replace(new RegExp(`[${s}]`,`gm${t}`),`[${s}]`),r.push(s);}return !0});}),e}createMergedBlanksRegExp(e){return e.replace(/[\s]+/gim,"[\\s]+")}createAccuracyRegExp(e){const t="!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~¡¿";let s=this.opt.accuracy,r="string"==typeof s?s:s.value,i="string"==typeof s?[]:s.limiters,o="";switch(i.forEach(e=>{o+=`|${this.escapeStr(e)}`;}),r){case"partially":default:return `()(${e})`;case"complementary":return `()([^${o="\\s"+(o||this.escapeStr(t))}]*${e}[^${o}]*)`;case"exactly":return `(^|\\s${o})(${e})(?=$|\\s${o})`}}getSeparatedKeywords(e){let t=[];return e.forEach(e=>{this.opt.separateWordSearch?e.split(" ").forEach(e=>{e.trim()&&-1===t.indexOf(e)&&t.push(e);}):e.trim()&&-1===t.indexOf(e)&&t.push(e);}),{keywords:t.sort((e,t)=>t.length-e.length),length:t.length}}isNumeric(e){return Number(parseFloat(e))==e}checkRanges(e){if(!Array.isArray(e)||"[object Object]"!==Object.prototype.toString.call(e[0]))return this.log("markRanges() will only accept an array of objects"),this.opt.noMatch(e),[];const t=[];let s=0;return e.sort((e,t)=>e.start-t.start).forEach(e=>{let{start:r,end:i,valid:o}=this.callNoMatchOnInvalidRanges(e,s);o&&(e.start=r,e.length=i-r,t.push(e),s=i);}),t}callNoMatchOnInvalidRanges(e,t){let s,r,i=!1;return e&&void 0!==e.start?(r=(s=parseInt(e.start,10))+parseInt(e.length,10),this.isNumeric(e.start)&&this.isNumeric(e.length)&&r-t>0&&r-s>0?i=!0:(this.log("Ignoring invalid or overlapping range: "+`${JSON.stringify(e)}`),this.opt.noMatch(e))):(this.log(`Ignoring invalid range: ${JSON.stringify(e)}`),this.opt.noMatch(e)),{start:s,end:r,valid:i}}checkWhitespaceRanges(e,t,s){let r,i=!0,o=s.length,n=t-o,a=parseInt(e.start,10)-n;return (r=(a=a>o?o:a)+parseInt(e.length,10))>o&&(r=o,this.log(`End range automatically set to the max value of ${o}`)),a<0||r-a<0||a>o||r>o?(i=!1,this.log(`Invalid range: ${JSON.stringify(e)}`),this.opt.noMatch(e)):""===s.substring(a,r).replace(/\s+/g,"")&&(i=!1,this.log("Skipping whitespace only range: "+JSON.stringify(e)),this.opt.noMatch(e)),{start:a,end:r,valid:i}}getTextNodes(e){let t="",s=[];this.iterator.forEachNode(NodeFilter.SHOW_TEXT,e=>{s.push({start:t.length,end:(t+=e.textContent).length,node:e});},e=>this.matchesExclude(e.parentNode)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT,()=>{e({value:t,nodes:s});});}matchesExclude(t){return e.matches(t,this.opt.exclude.concat(["script","style","title","head","html"]))}wrapRangeInTextNode(e,t,s){const r=this.opt.element?this.opt.element:"mark",i=e.splitText(t),o=i.splitText(s-t);let n=document.createElement(r);return n.setAttribute("data-markjs","true"),this.opt.className&&n.setAttribute("class",this.opt.className),n.textContent=i.textContent,i.parentNode.replaceChild(n,i),o}wrapRangeInMappedTextNode(e,t,s,r,i){e.nodes.every((o,n)=>{const a=e.nodes[n+1];if(void 0===a||a.start>t){if(!r(o.node))return !1;const a=t-o.start,c=(s>o.end?o.end:s)-o.start,h=e.value.substr(0,o.start),l=e.value.substr(c+o.start);if(o.node=this.wrapRangeInTextNode(o.node,a,c),e.value=h+l,e.nodes.forEach((t,s)=>{s>=n&&(e.nodes[s].start>0&&s!==n&&(e.nodes[s].start-=c),e.nodes[s].end-=c);}),s-=c,i(o.node.previousSibling,o.start),!(s>o.end))return !1;t=o.end;}return !0});}wrapMatches(e,t,s,r,i){const o=0===t?0:t+1;this.getTextNodes(t=>{t.nodes.forEach(t=>{let i;for(t=t.node;null!==(i=e.exec(t.textContent))&&""!==i[o];){if(!s(i[o],t))continue;let n=i.index;if(0!==o)for(let e=1;e<o;e++)n+=i[e].length;t=this.wrapRangeInTextNode(t,n,n+i[o].length),r(t.previousSibling),e.lastIndex=0;}}),i();});}wrapMatchesAcrossElements(e,t,s,r,i){const o=0===t?0:t+1;this.getTextNodes(t=>{let n;for(;null!==(n=e.exec(t.value))&&""!==n[o];){let i=n.index;if(0!==o)for(let e=1;e<o;e++)i+=n[e].length;const a=i+n[o].length;this.wrapRangeInMappedTextNode(t,i,a,e=>s(n[o],e),(t,s)=>{e.lastIndex=s,r(t);});}i();});}wrapRangeFromIndex(e,t,s,r){this.getTextNodes(i=>{const o=i.value.length;e.forEach((e,r)=>{let{start:n,end:a,valid:c}=this.checkWhitespaceRanges(e,o,i.value);c&&this.wrapRangeInMappedTextNode(i,n,a,s=>t(s,e,i.value.substring(n,a),r),t=>{s(t,e);});}),r();});}unwrapMatches(e){const t=e.parentNode;let s=document.createDocumentFragment();for(;e.firstChild;)s.appendChild(e.removeChild(e.firstChild));t.replaceChild(s,e),this.ie?this.normalizeTextNode(t):t.normalize();}normalizeTextNode(e){if(e){if(3===e.nodeType)for(;e.nextSibling&&3===e.nextSibling.nodeType;)e.nodeValue+=e.nextSibling.nodeValue,e.parentNode.removeChild(e.nextSibling);else this.normalizeTextNode(e.firstChild);this.normalizeTextNode(e.nextSibling);}}markRegExp(e,t){this.opt=t,this.log(`Searching with expression "${e}"`);let s=0,r="wrapMatches";const i=e=>{s++,this.opt.each(e);};this.opt.acrossElements&&(r="wrapMatchesAcrossElements"),this[r](e,this.opt.ignoreGroups,(e,t)=>this.opt.filter(t,e,s),i,()=>{0===s&&this.opt.noMatch(e),this.opt.done(s);});}mark(e,t){this.opt=t;let s=0,r="wrapMatches";const{keywords:i,length:o}=this.getSeparatedKeywords("string"==typeof e?[e]:e),n=this.opt.caseSensitive?"":"i",a=e=>{let t=new RegExp(this.createRegExp(e),`gm${n}`),c=0;this.log(`Searching with expression "${t}"`),this[r](t,1,(t,r)=>this.opt.filter(r,e,s,c),e=>{c++,s++,this.opt.each(e);},()=>{0===c&&this.opt.noMatch(e),i[o-1]===e?this.opt.done(s):a(i[i.indexOf(e)+1]);});};this.opt.acrossElements&&(r="wrapMatchesAcrossElements"),0===o?this.opt.done(s):a(i[0]);}markRanges(e,t){this.opt=t;let s=0,r=this.checkRanges(e);r&&r.length?(this.log("Starting to mark with the following ranges: "+JSON.stringify(r)),this.wrapRangeFromIndex(r,(e,t,s,r)=>this.opt.filter(e,t,s,r),(e,t)=>{s++,this.opt.each(e,t);},()=>{this.opt.done(s);})):this.opt.done(s);}unmark(t){this.opt=t;let s=this.opt.element?this.opt.element:"*";s+="[data-markjs]",this.opt.className&&(s+=`.${this.opt.className}`),this.log(`Removal selector "${s}"`),this.iterator.forEachNode(NodeFilter.SHOW_ELEMENT,e=>{this.unwrapMatches(e);},t=>{const r=e.matches(t,s),i=this.matchesExclude(t);return !r||i?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT},this.opt.done);}}return function(e){const s=new t(e);return this.mark=((e,t)=>(s.mark(e,t),this)),this.markRegExp=((e,t)=>(s.markRegExp(e,t),this)),this.markRanges=((e,t)=>(s.markRanges(e,t),this)),this.unmark=(e=>(s.unmark(e),this)),this}}); }); -export { mark_es6_min as default }; +export default mark_es6_min; diff --git a/webroot/js/web_modules/micromodal/dist/micromodal.min.js b/webroot/js/web_modules/micromodal/dist/micromodal.min.js index 931ba8591..abb52d3e7 100644 --- a/webroot/js/web_modules/micromodal/dist/micromodal.min.js +++ b/webroot/js/web_modules/micromodal/dist/micromodal.min.js @@ -5,6 +5,7 @@ var micromodal_min = createCommonjsModule(function (module, exports) { }); var close = micromodal_min.close; +export default micromodal_min; var init = micromodal_min.init; var show = micromodal_min.show; -export { micromodal_min as __moduleExports, close, micromodal_min as default, init, show }; +export { micromodal_min as __moduleExports, close, init, show }; diff --git a/webroot/js/web_modules/preact/hooks.js b/webroot/js/web_modules/preact/hooks.js new file mode 100644 index 000000000..74ae57744 --- /dev/null +++ b/webroot/js/web_modules/preact/hooks.js @@ -0,0 +1,5 @@ +import { options as l$1 } from '../preact.js'; + +var t,u,r,o=0,i=[],c=l$1.__b,f=l$1.__r,e=l$1.diffed,a=l$1.__c,v=l$1.unmount;function m(t,r){l$1.__h&&l$1.__h(u,t,o||r),o=0;var i=u.__H||(u.__H={__:[],__h:[]});return t>=i.__.length&&i.__.push({}),i.__[t]}function l(n){return o=1,p(w,n)}function p(n,r,o){var i=m(t++,2);return i.t=n,i.__c||(i.__=[o?o(r):w(void 0,r),function(n){var t=i.t(i.__[0],n);i.__[0]!==t&&(i.__=[t,i.__[1]],i.__c.setState({}));}],i.__c=u),i.__}function y(r,o){var i=m(t++,3);!l$1.__s&&k(i.__H,o)&&(i.__=r,i.__H=o,u.__H.__h.push(i));}function h(r,o){var i=m(t++,4);!l$1.__s&&k(i.__H,o)&&(i.__=r,i.__H=o,u.__h.push(i));}function s(n){return o=5,d(function(){return {current:n}},[])}function _(n,t,u){o=6,h(function(){"function"==typeof n?n(t()):n&&(n.current=t());},null==u?u:u.concat(n));}function d(n,u){var r=m(t++,7);return k(r.__H,u)&&(r.__=n(),r.__H=u,r.__h=n),r.__}function A(n,t){return o=8,d(function(){return n},t)}function F(n){var r=u.context[n.__c],o=m(t++,9);return o.c=n,r?(null==o.__&&(o.__=!0,r.sub(u)),r.props.value):n.__}function T(t,u){l$1.useDebugValue&&l$1.useDebugValue(u?u(t):t);}function q(n){var r=m(t++,10),o=l();return r.__=n,u.componentDidCatch||(u.componentDidCatch=function(n){r.__&&r.__(n),o[1](n);}),[o[0],function(){o[1](void 0);}]}function x(){var t;for(i.sort(function(n,t){return n.__v.__b-t.__v.__b});t=i.pop();)if(t.__P)try{t.__H.__h.forEach(g),t.__H.__h.forEach(j),t.__H.__h=[];}catch(u){t.__H.__h=[],l$1.__e(u,t.__v);}}l$1.__b=function(n){u=null,c&&c(n);},l$1.__r=function(n){f&&f(n),t=0;var r=(u=n.__c).__H;r&&(r.__h.forEach(g),r.__h.forEach(j),r.__h=[]);},l$1.diffed=function(t){e&&e(t);var o=t.__c;o&&o.__H&&o.__H.__h.length&&(1!==i.push(o)&&r===l$1.requestAnimationFrame||((r=l$1.requestAnimationFrame)||function(n){var t,u=function(){clearTimeout(r),b&&cancelAnimationFrame(t),setTimeout(n);},r=setTimeout(u,100);b&&(t=requestAnimationFrame(u));})(x)),u=null;},l$1.__c=function(t,u){u.some(function(t){try{t.__h.forEach(g),t.__h=t.__h.filter(function(n){return !n.__||j(n)});}catch(r){u.some(function(n){n.__h&&(n.__h=[]);}),u=[],l$1.__e(r,t.__v);}}),a&&a(t,u);},l$1.unmount=function(t){v&&v(t);var u,r=t.__c;r&&r.__H&&(r.__H.__.forEach(function(n){try{g(n);}catch(n){u=n;}}),u&&l$1.__e(u,r.__v));};var b="function"==typeof requestAnimationFrame;function g(n){var t=u,r=n.__c;"function"==typeof r&&(n.__c=void 0,r()),u=t;}function j(n){var t=u;n.__c=n.__(),u=t;}function k(n,t){return !n||n.length!==t.length||t.some(function(t,u){return t!==n[u]})}function w(n,t){return "function"==typeof t?t(n):t} + +export { A as useCallback, F as useContext, T as useDebugValue, y as useEffect, q as useErrorBoundary, _ as useImperativeHandle, h as useLayoutEffect, d as useMemo, p as useReducer, s as useRef, l as useState }; diff --git a/webroot/js/web_modules/videojs/dist/video.min.js b/webroot/js/web_modules/videojs/dist/video.min.js index 5f0e65ae7..e9686e259 100644 --- a/webroot/js/web_modules/videojs/dist/video.min.js +++ b/webroot/js/web_modules/videojs/dist/video.min.js @@ -30,4 +30,4 @@ var e;n(null,(e=s).subarray(0,e.byteLength-e[e.byteLength-1]));});}return u.prot var video_min$1 = /*@__PURE__*/getDefaultExportFromCjs(video_min); -export { video_min$1 as default }; +export default video_min$1; diff --git a/webroot/serviceWorker.js b/webroot/serviceWorker.js new file mode 100644 index 000000000..b2b7a4be9 --- /dev/null +++ b/webroot/serviceWorker.js @@ -0,0 +1,26 @@ +// self.addEventListener('activate', (event) => { +// console.log('Owncast service worker activated', event); +// }); + +// self.addEventListener('install', (event) => { +// console.log('installing Owncast service worker...', event); +// }); + +self.addEventListener('push', (event) => { + const data = JSON.parse(event.data.text()); + const { title, body, icon, tag } = data; + const options = { + title: title || 'Live!', + body: body || 'This live stream has started.', + icon: icon || '/logo/external', + tag: tag, + }; + + event.waitUntil(self.registration.showNotification(options.title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + let notification = event.notification; + console.log(notification); + clients.openWindow('/'); +}); |