mirror of
https://github.com/owncast/owncast.git
synced 2024-11-21 12:18:02 +03:00
Outbound live stream notifications (#1663)
* First pass at browser, discord, twilio notifications * Commit updated Javascript packages * Remove twilio notification support * Email notifications/smtp support * Fix Firefox notification support, remove chrome checks * WIP more email work * Add support for twitter notifications * Add stream title to discord and twitter notifications * Update notification registration modal * Fix hide/show email section * Commit updated API documentation * Commit updated Javascript packages * Fix post-rebase missing var * Remove unused var * Handle unsubscribe errors for browser push * Standardize email config prop names * Allow overriding go live email template * Some notifications cleanup * Commit updated Javascript packages * Remove email/smtp/mailjet support * Remove more references to email notifications Co-authored-by: Owncast <owncast@owncast.online>
This commit is contained in:
parent
4e415f7257
commit
4a17f30da8
39 changed files with 2209 additions and 3313 deletions
3139
build/javascript/package-lock.json
generated
3139
build/javascript/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||
|
|
85
controllers/admin/notifications.go
Normal file
85
controllers/admin/notifications.go
Normal file
|
@ -0,0 +1,85 @@
|
|||
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")
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "updated 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")
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "updated browser push config with provided values")
|
||||
}
|
||||
|
||||
// SetTwitterConfiguration will set the browser notification configuration.
|
||||
func SetTwitterConfiguration(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePOST(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
type request struct {
|
||||
Value models.TwitterConfiguration `json:"value"`
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
var config request
|
||||
if err := decoder.Decode(&config); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, "unable to update twitter config with provided values")
|
||||
return
|
||||
}
|
||||
|
||||
if err := data.SetTwitterConfiguration(config.Value); err != nil {
|
||||
controllers.WriteSimpleResponse(w, false, "unable to update twitter config with provided values")
|
||||
return
|
||||
}
|
||||
|
||||
controllers.WriteSimpleResponse(w, true, "updated twitter config with provided values")
|
||||
}
|
|
@ -77,6 +77,11 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
|
|||
ShowEngagement: data.GetFederationShowEngagement(),
|
||||
BlockedDomains: data.GetBlockedFederatedDomains(),
|
||||
},
|
||||
Notifications: notificationsConfigResponse{
|
||||
Discord: data.GetDiscordConfig(),
|
||||
Browser: data.GetBrowserPushConfig(),
|
||||
Twitter: data.GetTwitterConfiguration(),
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
@ -88,25 +93,26 @@ 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"`
|
||||
ChatJoinMessagesEnabled bool `json:"chatJoinMessagesEnabled"`
|
||||
ChatEstablishedUserMode bool `json:"chatEstablishedUserMode"`
|
||||
ExternalActions []models.ExternalAction `json:"externalActions"`
|
||||
SupportedCodecs []string `json:"supportedCodecs"`
|
||||
VideoCodec string `json:"videoCodec"`
|
||||
ForbiddenUsernames []string `json:"forbiddenUsernames"`
|
||||
Federation federationConfigResponse `json:"federation"`
|
||||
SuggestedUsernames []string `json:"suggestedUsernames"`
|
||||
SocketHostOverride string `json:"socketHostOverride,omitempty"`
|
||||
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"`
|
||||
ChatJoinMessagesEnabled bool `json:"chatJoinMessagesEnabled"`
|
||||
ChatEstablishedUserMode bool `json:"chatEstablishedUserMode"`
|
||||
ExternalActions []models.ExternalAction `json:"externalActions"`
|
||||
SupportedCodecs []string `json:"supportedCodecs"`
|
||||
VideoCodec string `json:"videoCodec"`
|
||||
ForbiddenUsernames []string `json:"forbiddenUsernames"`
|
||||
Federation federationConfigResponse `json:"federation"`
|
||||
SuggestedUsernames []string `json:"suggestedUsernames"`
|
||||
SocketHostOverride string `json:"socketHostOverride,omitempty"`
|
||||
Notifications notificationsConfigResponse `json:"notifications"`
|
||||
}
|
||||
|
||||
type videoSettings struct {
|
||||
|
@ -142,3 +148,9 @@ type federationConfigResponse struct {
|
|||
ShowEngagement bool `json:"showEngagement"`
|
||||
BlockedDomains []string `json:"blockedDomains"`
|
||||
}
|
||||
|
||||
type notificationsConfigResponse struct {
|
||||
Browser models.BrowserNotificationConfiguration `json:"browser"`
|
||||
Discord models.DiscordConfiguration `json:"discord"`
|
||||
Twitter models.TwitterConfiguration `json:"twitter"`
|
||||
}
|
||||
|
|
|
@ -12,24 +12,26 @@ 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"`
|
||||
SocketHostOverride string `json:"socketHostOverride,omitempty"`
|
||||
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"`
|
||||
SocketHostOverride string `json:"socketHostOverride,omitempty"`
|
||||
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 {
|
||||
|
@ -38,6 +40,15 @@ type federationConfigResponse struct {
|
|||
FollowerCount int `json:"followerCount,omitempty"`
|
||||
}
|
||||
|
||||
type browserNotificationsConfigResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PublicKey string `json:"publicKey,omitempty"`
|
||||
}
|
||||
|
||||
type notificationsConfigResponse struct {
|
||||
Browser browserNotificationsConfigResponse `json:"browser"`
|
||||
}
|
||||
|
||||
// GetWebConfig gets the status of the server.
|
||||
func GetWebConfig(w http.ResponseWriter, r *http.Request) {
|
||||
middleware.EnableCors(w)
|
||||
|
@ -72,6 +83,20 @@ 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,
|
||||
},
|
||||
}
|
||||
|
||||
configuration := webConfigResponse{
|
||||
Name: data.GetServerName(),
|
||||
Summary: serverSummary,
|
||||
|
@ -88,6 +113,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 {
|
||||
|
|
50
controllers/notifications.go
Normal file
50
controllers/notifications.go
Normal file
|
@ -0,0 +1,50 @@
|
|||
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")
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure the requested channel is one we want to handle.
|
||||
validTypes := []string{notifications.BrowserPushNotification}
|
||||
_, 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
|
||||
}
|
||||
}
|
10
core/core.go
10
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
|
||||
}
|
||||
|
||||
|
|
|
@ -14,49 +14,55 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
extraContentKey = "extra_page_content"
|
||||
streamTitleKey = "stream_title"
|
||||
streamKeyKey = "stream_key"
|
||||
logoPathKey = "logo_path"
|
||||
logoUniquenessKey = "logo_uniqueness"
|
||||
serverSummaryKey = "server_summary"
|
||||
serverWelcomeMessageKey = "server_welcome_message"
|
||||
serverNameKey = "server_name"
|
||||
serverURLKey = "server_url"
|
||||
httpPortNumberKey = "http_port_number"
|
||||
httpListenAddressKey = "http_listen_address"
|
||||
websocketHostOverrideKey = "websocket_host_override"
|
||||
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"
|
||||
suggestedUsernamesKey = "suggested_usernames"
|
||||
chatJoinMessagesEnabledKey = "chat_join_messages_enabled"
|
||||
chatEstablishedUsersOnlyModeKey = "chat_established_users_only_mode"
|
||||
extraContentKey = "extra_page_content"
|
||||
streamTitleKey = "stream_title"
|
||||
streamKeyKey = "stream_key"
|
||||
logoPathKey = "logo_path"
|
||||
logoUniquenessKey = "logo_uniqueness"
|
||||
serverSummaryKey = "server_summary"
|
||||
serverWelcomeMessageKey = "server_welcome_message"
|
||||
serverNameKey = "server_name"
|
||||
serverURLKey = "server_url"
|
||||
httpPortNumberKey = "http_port_number"
|
||||
httpListenAddressKey = "http_listen_address"
|
||||
websocketHostOverrideKey = "websocket_host_override"
|
||||
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"
|
||||
suggestedUsernamesKey = "suggested_usernames"
|
||||
chatJoinMessagesEnabledKey = "chat_join_messages_enabled"
|
||||
chatEstablishedUsersOnlyModeKey = "chat_established_users_only_mode"
|
||||
notificationsEnabledKey = "notifications_enabled"
|
||||
discordConfigurationKey = "discord_configuration"
|
||||
browserPushConfigurationKey = "browser_push_configuration"
|
||||
browserPushPublicKeyKey = "browser_push_public_key"
|
||||
browserPushPrivateKeyKey = "browser_push_private_key"
|
||||
twitterConfigurationKey = "twitter_configuration"
|
||||
hasConfiguredInitialNotificationsKey = "has_configured_initial_notifications"
|
||||
)
|
||||
|
||||
// GetExtraPageBodyContent will return the user-supplied body content.
|
||||
|
@ -446,22 +452,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)
|
||||
|
@ -816,3 +806,108 @@ func GetChatJoinMessagesEnabled() bool {
|
|||
|
||||
return enabled
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// GetBrowserPushConfig 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)
|
||||
}
|
||||
|
||||
// SetTwitterConfiguration will set the Twitter configuration.
|
||||
func SetTwitterConfiguration(config models.TwitterConfiguration) error {
|
||||
configEntry := ConfigEntry{Key: twitterConfigurationKey, Value: config}
|
||||
return _datastore.Save(configEntry)
|
||||
}
|
||||
|
||||
// GetTwitterConfiguration will return the Twitter configuration.
|
||||
func GetTwitterConfiguration() models.TwitterConfiguration {
|
||||
configEntry, err := _datastore.Get(twitterConfigurationKey)
|
||||
if err != nil {
|
||||
return models.TwitterConfiguration{Enabled: false}
|
||||
}
|
||||
|
||||
var config models.TwitterConfiguration
|
||||
if err := configEntry.getObject(&config); err != nil {
|
||||
return models.TwitterConfiguration{Enabled: false}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
@ -71,10 +74,8 @@ func setStreamAsConnected(rtmpOut *io.PipeReader) {
|
|||
_ = chat.SendSystemAction("Stay tuned, the stream is **starting**!", true)
|
||||
chat.SendAllWelcomeMessage()
|
||||
|
||||
// Send a delayed live Federated message.
|
||||
if data.GetFederationEnabled() {
|
||||
_onlineTimerCancelFunc = startFederatedLiveStreamMessageTimer()
|
||||
}
|
||||
// Send delayed notification messages.
|
||||
_onlineTimerCancelFunc = startLiveStreamNotificationsTimer()
|
||||
}
|
||||
|
||||
// SetStreamAsDisconnected sets the stream as disconnected.
|
||||
|
@ -161,19 +162,37 @@ func stopOnlineCleanupTimer() {
|
|||
}
|
||||
}
|
||||
|
||||
func startFederatedLiveStreamMessageTimer() context.CancelFunc {
|
||||
// Send a delayed live Federated message.
|
||||
func startLiveStreamNotificationsTimer() context.CancelFunc {
|
||||
// Send delayed notification messages.
|
||||
c, cancelFunc := context.WithCancel(context.Background())
|
||||
_onlineTimerCancelFunc = cancelFunc
|
||||
go func(c context.Context) {
|
||||
select {
|
||||
case <-time.After(time.Minute * 2.0):
|
||||
log.Traceln("Sending Federated Go Live message.")
|
||||
if err := activitypub.SendLive(); err != nil {
|
||||
log.Errorln(err)
|
||||
if _lastNotified != nil && time.Since(*_lastNotified) < 10*time.Minute {
|
||||
return
|
||||
}
|
||||
|
||||
// Send Fediverse message.
|
||||
if data.GetFederationEnabled() {
|
||||
log.Traceln("Sending Federated Go Live message.")
|
||||
if err := activitypub.SendLive(); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Send notification to those who have registered for them.
|
||||
if notifier, err := notifications.New(data.GetDatastore()); err != nil {
|
||||
log.Errorln(err)
|
||||
} else {
|
||||
notifier.Notify()
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_lastNotified = &now
|
||||
case <-c.Done():
|
||||
}
|
||||
}(c)
|
||||
|
||||
return cancelFunc
|
||||
}
|
||||
|
|
|
@ -40,3 +40,10 @@ type IpBan struct {
|
|||
Notes sql.NullString
|
||||
CreatedAt sql.NullTime
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID int32
|
||||
Channel string
|
||||
Destination string
|
||||
CreatedAt sql.NullTime
|
||||
}
|
||||
|
|
|
@ -70,3 +70,11 @@ SELECT count(*) FROM ip_bans WHERE ip_address = $1;
|
|||
|
||||
-- name: GetIPAddressBans :many
|
||||
SELECT * FROM ip_bans;
|
||||
-- name: AddNotification :exec
|
||||
INSERT INTO notifications (channel, destination) VALUES($1, $2);
|
||||
|
||||
-- name: GetNotificationDestinationsForChannel :many
|
||||
SELECT destination FROM notifications WHERE channel = $1;
|
||||
|
||||
-- name: RemoveNotificationDestinationForChannel :exec
|
||||
DELETE FROM notifications WHERE channel = $1 AND destination = $2;
|
||||
|
|
|
@ -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)
|
||||
`
|
||||
|
@ -343,6 +357,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
|
||||
`
|
||||
|
@ -489,6 +530,20 @@ func (q *Queries) RemoveIPAddressBan(ctx context.Context, ipAddress string) erro
|
|||
return err
|
||||
}
|
||||
|
||||
const removeNotificationDestinationForChannel = `-- name: RemoveNotificationDestinationForChannel :exec
|
||||
DELETE FROM notifications WHERE channel = $1 AND destination = $2
|
||||
`
|
||||
|
||||
type RemoveNotificationDestinationForChannelParams struct {
|
||||
Channel string
|
||||
Destination string
|
||||
}
|
||||
|
||||
func (q *Queries) RemoveNotificationDestinationForChannel(ctx context.Context, arg RemoveNotificationDestinationForChannelParams) error {
|
||||
_, err := q.db.ExecContext(ctx, removeNotificationDestinationForChannel, arg.Channel, arg.Destination)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateFollowerByIRI = `-- name: UpdateFollowerByIRI :exec
|
||||
UPDATE ap_followers SET inbox = $1, name = $2, username = $3, image = $4 WHERE iri = $5
|
||||
`
|
||||
|
|
|
@ -41,3 +41,10 @@ CREATE TABLE IF NOT EXISTS ap_accepted_activities (
|
|||
"notes" TEXT,
|
||||
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
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);
|
||||
|
|
File diff suppressed because one or more lines are too long
20
go.mod
20
go.mod
|
@ -27,18 +27,14 @@ 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.9 // indirect
|
||||
github.com/tklauser/numcpus v0.3.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.2 // indirect
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect
|
||||
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f // indirect
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect
|
||||
)
|
||||
|
@ -63,4 +59,18 @@ require (
|
|||
github.com/shirou/gopsutil/v3 v3.22.2
|
||||
)
|
||||
|
||||
require github.com/SherClockHolmes/webpush-go v1.1.3
|
||||
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/dghubble/oauth1 v0.7.1
|
||||
github.com/g8rswimmer/go-twitter v1.1.5-0.20220129031223-e62b74626b0a
|
||||
github.com/go-test/deep v1.0.4 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394
|
||||
github.com/oschwald/maxminddb-golang v1.8.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/go-fed/activity => github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026
|
||||
|
|
27
go.sum
27
go.sum
|
@ -33,6 +33,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
|
|||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
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/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
|
@ -61,10 +63,14 @@ github.com/dave/jennifer v1.3.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhr
|
|||
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=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dghubble/oauth1 v0.7.1 h1:JjbOVSVVkms9A4h/sTQy5Jb2nFuAAVb2qVYgenJPyrE=
|
||||
github.com/dghubble/oauth1 v0.7.1/go.mod h1:0eEzON0UY/OLACQrmnjgJjmvCGXzjBCsZqL1kWDXtF0=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/g8rswimmer/go-twitter v1.1.5-0.20220129031223-e62b74626b0a h1:WFj29uFGJL56FyeXEgw8oLOhL5UmQsLU7C29BjCT3F4=
|
||||
github.com/g8rswimmer/go-twitter v1.1.5-0.20220129031223-e62b74626b0a/go.mod h1:2fqT4zKHjUTOd2C8YneuRJFieYfbA3NWVSEeD+qIYHs=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
|
@ -80,9 +86,12 @@ github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG
|
|||
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-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
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/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
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/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
|
@ -177,6 +186,8 @@ github.com/lestrrat-go/strftime v1.0.4 h1:T1Rb9EPkAhgxKqbcMIPguPq8glqXTA1koF8n9B
|
|||
github.com/lestrrat-go/strftime v1.0.4/go.mod h1:E1nN3pCbtMSu1yjSVeyuRFVm/U0xoR76fd03sz+Qz4g=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394 h1:+6kiV40vfmh17TDlZG15C2uGje1/XBGT32j6xKmUkqM=
|
||||
github.com/mailjet/mailjet-apiv3-go v0.0.0-20201009050126-c24bc15a9394/go.mod h1:ogN8Sxy3n5VKLhQxbtSBM3ICG/VgjXS/akQJIoDSrgA=
|
||||
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.10 h1:MLn+5bFRlWMGoSRmJour3CL1w/qL96mvipqpwQW/Sfk=
|
||||
github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
|
@ -197,10 +208,9 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
|
|||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nakabonne/tstorage v0.3.5 h1:AmXhEn6SM94sMy1+bwAs9xg3cuefXBXakcYOMQuQlqI=
|
||||
github.com/nakabonne/tstorage v0.3.5/go.mod h1:dgOHx150reQ3xHCqyoU19TImAU0PY78bfwUIG24xNzY=
|
||||
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/nareix/joy5 v0.0.0-20210317075623-2c912ca30590 h1:PnxRU8L8Y2q82vFC2QdNw23Dm2u6WrjecIdpXjiYbXM=
|
||||
github.com/nareix/joy5 v0.0.0-20210317075623-2c912ca30590/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.6.1 h1:GKxT3yaWWNXSb7vj6D7eoJBns+lGYgx08QO0UcNm0YY=
|
||||
github.com/oschwald/geoip2-golang v1.6.1/go.mod h1:xdvYt5xQzB8ORWFqPnqMwZpCpgNagttWdoZLlJQzg7s=
|
||||
github.com/oschwald/maxminddb-golang v1.8.0 h1:Uh/DSnGoxsyp/KYbY1AuP0tYEwfs0sCph9p/UMXK/Hk=
|
||||
|
@ -259,8 +269,6 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
|||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf h1:Z2X3Os7oRzpdJ75iPqWZc0HeJWFYNCvKsfpQwFpRNTA=
|
||||
github.com/teris-io/shortid v0.0.0-20171029131806-771a37caa5cf/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0=
|
||||
github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125 h1:3SNcvBmEPE1YlB1JpVZouslJpI3GBNoiqW7+wb0Rz7w=
|
||||
github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0=
|
||||
github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo=
|
||||
|
@ -281,12 +289,14 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
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/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
|
@ -350,6 +360,7 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
|
|||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f h1:hEYJvxw1lSnWIl8X9ofsYMklzaDs90JI2az5YMd4fPM=
|
||||
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
|
@ -407,10 +418,12 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/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-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/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.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
@ -422,8 +435,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
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/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
|
||||
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
|
27
models/notification.go
Normal file
27
models/notification.go
Normal file
|
@ -0,0 +1,27 @@
|
|||
package models
|
||||
|
||||
// DiscordConfiguration represents the configuration for the discord
|
||||
// notification service.
|
||||
type DiscordConfiguration struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Webhook string `json:"webhook,omitempty"`
|
||||
GoLiveMessage string `json:"goLiveMessage,omitempty"`
|
||||
}
|
||||
|
||||
// BrowserNotificationConfiguration represents the configuration for
|
||||
// browser notifications.
|
||||
type BrowserNotificationConfiguration struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
GoLiveMessage string `json:"goLiveMessage,omitempty"`
|
||||
}
|
||||
|
||||
// TwitterConfiguration represents the configuration for Twitter access.
|
||||
type TwitterConfiguration struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"apiKey"` // aka consumer key
|
||||
APISecret string `json:"apiSecret"` // aka consumer secret
|
||||
AccessToken string `json:"accessToken"`
|
||||
AccessTokenSecret string `json:"accessTokenSecret"`
|
||||
BearerToken string `json:"bearerToken"`
|
||||
GoLiveMessage string `json:"goLiveMessage,omitempty"`
|
||||
}
|
83
notifications/browser/browser.go
Normal file
83
notifications/browser/browser.go
Normal file
|
@ -0,0 +1,83 @@
|
|||
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,
|
||||
) (bool, 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 false, errors.Wrap(err, "error marshalling web push message")
|
||||
}
|
||||
|
||||
// Decode subscription
|
||||
s := &webpush.Subscription{}
|
||||
if err := json.Unmarshal([]byte(subscription), s); err != nil {
|
||||
return false, 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,
|
||||
// Not really the subscriber, but a contact point for the sender.
|
||||
Subscriber: "owncast@owncast.online",
|
||||
})
|
||||
if resp.StatusCode == 410 {
|
||||
return true, nil
|
||||
} else if err != nil {
|
||||
return false, errors.Wrap(err, "error sending browser push notification")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return false, err
|
||||
}
|
6
notifications/channels.go
Normal file
6
notifications/channels.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package notifications
|
||||
|
||||
const (
|
||||
// BrowserPushNotification represents a push notification for a browser.
|
||||
BrowserPushNotification = "BROWSER_PUSH_NOTIFICATION"
|
||||
)
|
61
notifications/discord/discord.go
Normal file
61
notifications/discord/discord.go
Normal file
|
@ -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()
|
||||
}
|
193
notifications/notifications.go
Normal file
193
notifications/notifications.go
Normal file
|
@ -0,0 +1,193 @@
|
|||
package notifications
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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/twitter"
|
||||
"github.com/owncast/owncast/utils"
|
||||
"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
|
||||
twitter *twitter.Twitter
|
||||
}
|
||||
|
||||
// Setup will perform any pre-use setup for the notifier.
|
||||
func Setup(datastore *data.Datastore) {
|
||||
createNotificationsTable(datastore.DB)
|
||||
initializeBrowserPushIfNeeded()
|
||||
}
|
||||
|
||||
func initializeBrowserPushIfNeeded() {
|
||||
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,
|
||||
}
|
||||
|
||||
if err := notifier.setupBrowserPush(); err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
if err := notifier.setupDiscord(); err != nil {
|
||||
log.Error(err)
|
||||
}
|
||||
if err := notifier.setupTwitter(); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
|
||||
return ¬ifier, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) setupBrowserPush() error {
|
||||
if data.GetBrowserPushConfig().Enabled {
|
||||
publicKey, err := data.GetBrowserPushPublicKey()
|
||||
if err != nil || publicKey == "" {
|
||||
return errors.Wrap(err, "browser notifier disabled, failed to get browser push public key")
|
||||
}
|
||||
|
||||
privateKey, err := data.GetBrowserPushPrivateKey()
|
||||
if err != nil || privateKey == "" {
|
||||
return errors.Wrap(err, "browser notifier disabled, failed to get browser push private key")
|
||||
}
|
||||
|
||||
browserNotifier, err := browser.New(n.datastore, publicKey, privateKey)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error creating browser notifier")
|
||||
}
|
||||
n.browser = browserNotifier
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Notifier) notifyBrowserPush() {
|
||||
destinations, err := GetNotificationDestinationsForChannel(BrowserPushNotification)
|
||||
if err != nil {
|
||||
log.Errorln("error getting browser push notification destinations", err)
|
||||
}
|
||||
for _, destination := range destinations {
|
||||
unsubscribed, err := n.browser.Send(destination, data.GetServerName(), data.GetBrowserPushConfig().GoLiveMessage)
|
||||
if unsubscribed {
|
||||
// If the error is "unsubscribed", then remove the destination from the database.
|
||||
if err := RemoveNotificationForChannel(BrowserPushNotification, destination); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
} else if err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notifier) setupDiscord() error {
|
||||
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 errors.Wrap(err, "error creating discord notifier")
|
||||
}
|
||||
n.discord = discordNotifier
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Notifier) notifyDiscord() {
|
||||
goLiveMessage := data.GetDiscordConfig().GoLiveMessage
|
||||
streamTitle := data.GetStreamTitle()
|
||||
if streamTitle != "" {
|
||||
goLiveMessage += "\n" + streamTitle
|
||||
}
|
||||
message := fmt.Sprintf("%s\n\n%s", goLiveMessage, data.GetServerURL())
|
||||
|
||||
if err := n.discord.Send(message); err != nil {
|
||||
log.Errorln("error sending discord message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notifier) setupTwitter() error {
|
||||
if twitterConfig := data.GetTwitterConfiguration(); twitterConfig.Enabled {
|
||||
if t, err := twitter.New(twitterConfig.APIKey, twitterConfig.APISecret, twitterConfig.AccessToken, twitterConfig.AccessTokenSecret, twitterConfig.BearerToken); err == nil {
|
||||
n.twitter = t
|
||||
} else if err != nil {
|
||||
return errors.Wrap(err, "error creating twitter notifier")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *Notifier) notifyTwitter() {
|
||||
goLiveMessage := data.GetTwitterConfiguration().GoLiveMessage
|
||||
streamTitle := data.GetStreamTitle()
|
||||
if streamTitle != "" {
|
||||
goLiveMessage += "\n" + streamTitle
|
||||
}
|
||||
tagString := ""
|
||||
for _, tag := range utils.ShuffleStringSlice(data.GetServerMetadataTags()) {
|
||||
tagString = fmt.Sprintf("%s #%s", tagString, tag)
|
||||
}
|
||||
tagString = strings.TrimSpace(tagString)
|
||||
|
||||
message := fmt.Sprintf("%s\n%s\n\n%s", goLiveMessage, data.GetServerURL(), tagString)
|
||||
|
||||
if err := n.twitter.Notify(message); err != nil {
|
||||
log.Errorln("error sending twitter message", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify will fire the different notification channels.
|
||||
func (n *Notifier) Notify() {
|
||||
if n.browser != nil {
|
||||
n.notifyBrowserPush()
|
||||
}
|
||||
|
||||
if n.discord != nil {
|
||||
n.notifyDiscord()
|
||||
}
|
||||
|
||||
if n.twitter != nil {
|
||||
n.notifyTwitter()
|
||||
}
|
||||
}
|
60
notifications/persistence.go
Normal file
60
notifications/persistence.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
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, destination string) error {
|
||||
return data.GetDatastore().GetQueries().AddNotification(context.Background(), db.AddNotificationParams{
|
||||
Channel: channel,
|
||||
Destination: destination,
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveNotificationForChannel removes a notification destination..
|
||||
func RemoveNotificationForChannel(channel, destination string) error {
|
||||
log.Println("Removing notification for channel", channel)
|
||||
return data.GetDatastore().GetQueries().RemoveNotificationDestinationForChannel(context.Background(), db.RemoveNotificationDestinationForChannelParams{
|
||||
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
|
||||
}
|
78
notifications/twitter/twitter.go
Normal file
78
notifications/twitter/twitter.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
package twitter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/dghubble/oauth1"
|
||||
"github.com/g8rswimmer/go-twitter/v2"
|
||||
)
|
||||
|
||||
/*
|
||||
1. developer.twitter.com. Apply to be a developer if needed.
|
||||
2. Projects and apps -> Your project name
|
||||
3. Settings.
|
||||
4. Scroll down to"User authentication settings" Edit
|
||||
5. Enable OAuth 1.0a with Read/Write permissions.
|
||||
6. Fill out the form with your information. Callback can be anything.
|
||||
7. Go to your project "Keys and tokens"
|
||||
8. Generate API key and secret.
|
||||
9. Generate access token and secret. Verify it says "Read and write permissions."
|
||||
10. Generate bearer token.
|
||||
*/
|
||||
|
||||
type authorize struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
func (a authorize) Add(req *http.Request) {
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", a.Token))
|
||||
}
|
||||
|
||||
// Twitter is an instance of the Twitter notifier.
|
||||
type Twitter struct {
|
||||
apiKey string
|
||||
apiSecret string
|
||||
accessToken string
|
||||
accessTokenSecret string
|
||||
bearerToken string
|
||||
}
|
||||
|
||||
// New returns a new instance of the Twitter notifier.
|
||||
func New(apiKey, apiSecret, accessToken, accessTokenSecret, bearerToken string) (*Twitter, error) {
|
||||
if apiKey == "" || apiSecret == "" || accessToken == "" || accessTokenSecret == "" || bearerToken == "" {
|
||||
return nil, errors.New("missing some or all of the required twitter configuration values")
|
||||
}
|
||||
|
||||
return &Twitter{
|
||||
apiKey: apiKey,
|
||||
apiSecret: apiSecret,
|
||||
accessToken: accessToken,
|
||||
accessTokenSecret: accessTokenSecret,
|
||||
bearerToken: bearerToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Notify will send a notification to Twitter with the supplied text.
|
||||
func (t *Twitter) Notify(text string) error {
|
||||
config := oauth1.NewConfig(t.apiKey, t.apiSecret)
|
||||
token := oauth1.NewToken(t.accessToken, t.accessTokenSecret)
|
||||
httpClient := config.Client(oauth1.NoContext, token)
|
||||
|
||||
client := &twitter.Client{
|
||||
Authorizer: authorize{
|
||||
Token: t.bearerToken,
|
||||
},
|
||||
Client: httpClient,
|
||||
Host: "https://api.twitter.com",
|
||||
}
|
||||
|
||||
req := twitter.CreateTweetRequest{
|
||||
Text: text,
|
||||
}
|
||||
|
||||
_, err := client.CreateTweet(context.Background(), req)
|
||||
return err
|
||||
}
|
|
@ -83,6 +83,9 @@ func Start() error {
|
|||
// save client video playback metrics
|
||||
http.HandleFunc("/api/metrics/playback", controllers.ReportPlaybackMetrics)
|
||||
|
||||
// Register for notifications
|
||||
http.HandleFunc("/api/notifications/register", middleware.RequireUserAccessToken(controllers.RegisterForLiveNotifications))
|
||||
|
||||
// Authenticated admin requests
|
||||
|
||||
// Current inbound broadcaster
|
||||
|
@ -339,6 +342,11 @@ func Start() error {
|
|||
promhttp.Handler().ServeHTTP(rw, r)
|
||||
}))
|
||||
|
||||
// Configure outbound notification channels.
|
||||
http.HandleFunc("/api/admin/config/notifications/discord", middleware.RequireAdminAuth(admin.SetDiscordNotificationConfiguration))
|
||||
http.HandleFunc("/api/admin/config/notifications/browser", middleware.RequireAdminAuth(admin.SetBrowserNotificationConfiguration))
|
||||
http.HandleFunc("/api/admin/config/notifications/twitter", middleware.RequireAdminAuth(admin.SetTwitterConfiguration))
|
||||
|
||||
// ActivityPub has its own router
|
||||
activitypub.Start(data.GetDatastore())
|
||||
|
||||
|
|
487
static/golive.html.tmpl
vendored
Normal file
487
static/golive.html.tmpl
vendored
Normal file
|
@ -0,0 +1,487 @@
|
|||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:o="urn:schemas-microsoft-com:office:office"
|
||||
>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<!-- utf-8 works for most cases -->
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<!-- Forcing initial-scale shouldn't be necessary -->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<!-- Use the latest (edge) version of IE rendering engine -->
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
<!-- Disable auto-scale in iOS 10 Mail entirely -->
|
||||
<title></title>
|
||||
<!-- The title tag shows in email notifications, like Android 4.4. -->
|
||||
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Lato:300,400,700"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- CSS Reset : BEGIN -->
|
||||
<style>
|
||||
/* What it does: Remove spaces around the email design added by some email clients. */
|
||||
/* Beware: It can remove the padding / margin and add a background color to the compose a reply window. */
|
||||
html,
|
||||
body {
|
||||
margin: 0 auto !important;
|
||||
padding: 0 !important;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
/* What it does: Stops email clients resizing small text. */
|
||||
* {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* What it does: Centers email on Android 4.4 */
|
||||
div[style*='margin: 16px 0'] {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* What it does: Stops Outlook from adding extra spacing to tables. */
|
||||
table,
|
||||
td {
|
||||
mso-table-lspace: 0pt !important;
|
||||
mso-table-rspace: 0pt !important;
|
||||
}
|
||||
|
||||
/* What it does: Fixes webkit padding issue. */
|
||||
table {
|
||||
border-spacing: 0 !important;
|
||||
border-collapse: collapse !important;
|
||||
table-layout: fixed !important;
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
/* What it does: Uses a better rendering method when resizing images in IE. */
|
||||
img {
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
/* What it does: Prevents Windows 10 Mail from underlining links despite inline CSS. Styles for underlined links should be inline. */
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* What it does: A work-around for email clients meddling in triggered links. */
|
||||
*[x-apple-data-detectors], /* iOS */
|
||||
.unstyle-auto-detected-links *,
|
||||
.aBn {
|
||||
border-bottom: 0 !important;
|
||||
cursor: default !important;
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
font-size: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
}
|
||||
|
||||
/* What it does: Prevents Gmail from displaying a download button on large, non-linked images. */
|
||||
.a6S {
|
||||
display: none !important;
|
||||
opacity: 0.01 !important;
|
||||
}
|
||||
|
||||
/* What it does: Prevents Gmail from changing the text color in conversation threads. */
|
||||
.im {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
/* If the above doesn't work, add a .g-img class to any image in question. */
|
||||
img.g-img + div {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* What it does: Removes right gutter in Gmail iOS app: https://github.com/TedGoas/Cerberus/issues/89 */
|
||||
/* Create one of these media queries for each additional viewport size you'd like to fix */
|
||||
|
||||
/* iPhone 4, 4S, 5, 5S, 5C, and 5SE */
|
||||
@media only screen and (min-device-width: 320px) and (max-device-width: 374px) {
|
||||
u ~ div .email-container {
|
||||
min-width: 320px !important;
|
||||
}
|
||||
}
|
||||
/* iPhone 6, 6S, 7, 8, and X */
|
||||
@media only screen and (min-device-width: 375px) and (max-device-width: 413px) {
|
||||
u ~ div .email-container {
|
||||
min-width: 375px !important;
|
||||
}
|
||||
}
|
||||
/* iPhone 6+, 7+, and 8+ */
|
||||
@media only screen and (min-device-width: 414px) {
|
||||
u ~ div .email-container {
|
||||
min-width: 414px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- CSS Reset : END -->
|
||||
|
||||
<!-- Progressive Enhancements : BEGIN -->
|
||||
<style>
|
||||
.primary {
|
||||
background: #6655b3;
|
||||
}
|
||||
.bg_white {
|
||||
background: #ffffff;
|
||||
}
|
||||
.bg_light {
|
||||
background: #fafafa;
|
||||
}
|
||||
.bg_black {
|
||||
background: #000000;
|
||||
}
|
||||
.bg_dark {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.email-section {
|
||||
padding: 2.5em;
|
||||
}
|
||||
|
||||
/*BUTTON*/
|
||||
.btn {
|
||||
padding: 10px 15px;
|
||||
display: inline-block;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
.btn.btn-primary {
|
||||
border-radius: 5px;
|
||||
background: #6655b3;
|
||||
color: #ffffff;
|
||||
}
|
||||
.btn.btn-white {
|
||||
border-radius: 5px;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
.btn.btn-white-outline {
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
border: 1px solid #fff;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.btn-black-outline {
|
||||
border-radius: 0px;
|
||||
background: transparent;
|
||||
border: 2px solid #000;
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'Lato', sans-serif;
|
||||
color: #000000;
|
||||
margin-top: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Lato', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #6655b3;
|
||||
}
|
||||
|
||||
table {
|
||||
}
|
||||
/*LOGO*/
|
||||
|
||||
.logo h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.logo h1 a {
|
||||
color: #6655b3;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
font-family: 'Lato', sans-serif;
|
||||
}
|
||||
|
||||
/*HERO*/
|
||||
.hero {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero .text {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.hero .text h2 {
|
||||
color: #000;
|
||||
font-size: 40px;
|
||||
margin-bottom: 0;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.hero .text h3 {
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
}
|
||||
.hero .text h2 span {
|
||||
font-weight: 600;
|
||||
color: #6655b3;
|
||||
}
|
||||
|
||||
/*HEADING SECTION*/
|
||||
.heading-section {
|
||||
}
|
||||
.heading-section h2 {
|
||||
color: #000000;
|
||||
font-size: 28px;
|
||||
margin-top: 0;
|
||||
line-height: 1.4;
|
||||
font-weight: 400;
|
||||
}
|
||||
.heading-section .subheading {
|
||||
margin-bottom: 20px !important;
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
}
|
||||
.heading-section .subheading::after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -10px;
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #6655b3;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.heading-section-white {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.heading-section-white h2 {
|
||||
line-height: 1;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.heading-section-white h2 {
|
||||
color: #ffffff;
|
||||
}
|
||||
.heading-section-white .subheading {
|
||||
margin-bottom: 0;
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
ul.social {
|
||||
padding: 0;
|
||||
}
|
||||
ul.social li {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
/*FOOTER*/
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.footer .heading {
|
||||
color: #000;
|
||||
font-size: 20px;
|
||||
}
|
||||
.footer ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.footer ul li {
|
||||
list-style: none;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.footer ul li a {
|
||||
color: rgba(0, 0, 0, 1);
|
||||
}
|
||||
|
||||
#owncast-promo {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body
|
||||
width="100%"
|
||||
style="
|
||||
margin: 0;
|
||||
padding: 0 !important;
|
||||
mso-line-height-rule: exactly;
|
||||
background-color: #f1f1f1;
|
||||
"
|
||||
>
|
||||
<center style="width: 100%; background-color: #f1f1f1">
|
||||
<div
|
||||
style="
|
||||
display: none;
|
||||
font-size: 1px;
|
||||
max-height: 0px;
|
||||
max-width: 0px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
mso-hide: all;
|
||||
font-family: sans-serif;
|
||||
"
|
||||
>
|
||||
‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌
|
||||
</div>
|
||||
<div style="max-width: 600px; margin: 0 auto" class="email-container">
|
||||
<!-- BEGIN BODY -->
|
||||
<table
|
||||
align="center"
|
||||
role="presentation"
|
||||
cellspacing="0"
|
||||
cellpadding="0"
|
||||
border="0"
|
||||
width="100%"
|
||||
style="margin: auto"
|
||||
>
|
||||
<tr>
|
||||
<td
|
||||
valign="top"
|
||||
class="bg_white"
|
||||
style="padding: 1em 2.5em 0 2.5em"
|
||||
>
|
||||
<table
|
||||
role="presentation"
|
||||
border="0"
|
||||
cellpadding="0"
|
||||
cellspacing="0"
|
||||
width="100%"
|
||||
>
|
||||
<tr>
|
||||
<td class="logo" style="text-align: center">
|
||||
<h1><a href="{{.ServerURL}}">{{.ServerName}}</a></h1>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- end tr -->
|
||||
<tr>
|
||||
<td
|
||||
valign="middle"
|
||||
class="hero bg_white"
|
||||
style="padding: 3em 0 2em 0"
|
||||
>
|
||||
<a href="{{.ServerURL}}">
|
||||
<img
|
||||
src="{{.Logo}}"
|
||||
alt=""
|
||||
style="
|
||||
width: 100px;
|
||||
max-width: 600px;
|
||||
height: auto;
|
||||
margin: auto;
|
||||
display: block;
|
||||
"
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- end tr -->
|
||||
<tr>
|
||||
<td
|
||||
valign="middle"
|
||||
class="hero bg_white"
|
||||
style="padding: 2em 0 4em 0"
|
||||
>
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<div
|
||||
class="text"
|
||||
style="padding: 0 2.5em; text-align: center"
|
||||
>
|
||||
<a href="{{.ServerURL}}">
|
||||
<img
|
||||
src="{{.Thumbnail}}"
|
||||
alt=""
|
||||
style="
|
||||
width: 300px;
|
||||
max-width: 600px;
|
||||
height: auto;
|
||||
margin: auto;
|
||||
display: block;
|
||||
"
|
||||
/>
|
||||
</a>
|
||||
<h2><a href="{{.ServerURL}}">{{.ServerName}}</h2></a>
|
||||
<h3>{{.StreamDescription}}</h3>
|
||||
<p>
|
||||
<a href="{{.ServerURL}}" class="btn btn-primary"
|
||||
>Watch now!</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- end tr -->
|
||||
<!-- 1 Column Text + Button : END -->
|
||||
</table>
|
||||
<table
|
||||
align="center"
|
||||
role="presentation"
|
||||
cellspacing="0"
|
||||
cellpadding="0"
|
||||
border="0"
|
||||
width="100%"
|
||||
style="margin: auto"
|
||||
>
|
||||
<!-- end: tr -->
|
||||
<tr>
|
||||
<td class="bg_light" style="text-align: center">
|
||||
<p>
|
||||
No longer want to receive emails from {{.ServerName}}? You should
|
||||
<a href="[[UNSUB_LINK_EN]]" style="color: rgba(0, 0, 0, 0.8)"
|
||||
>unsubscribe here</a
|
||||
>.
|
||||
</p>
|
||||
<p id="owncast-promo">
|
||||
This stream is powered by
|
||||
<a href="https://owncast.online"
|
||||
><img
|
||||
src="https://owncast.online/images/logo.svg"
|
||||
width="10px"
|
||||
/> Owncast</a
|
||||
>
|
||||
and you can run your own, too.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</center>
|
||||
</body>
|
||||
</html>
|
|
@ -13,6 +13,7 @@ import (
|
|||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mssola/user_agent"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
@ -366,3 +367,12 @@ func GetHashtagsFromText(text string) []string {
|
|||
re := regexp.MustCompile(`#[a-zA-Z0-9_]+`)
|
||||
return re.FindAllString(text, -1)
|
||||
}
|
||||
|
||||
// ShuffleStringSlice will shuffle a slice of strings.
|
||||
func ShuffleStringSlice(s []string) []string {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
rand.Shuffle(len(s), func(i, j int) {
|
||||
s[i], s[j] = s[j], s[i]
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
|
BIN
webroot/img/browser-push-notifications-settings.png
Normal file
BIN
webroot/img/browser-push-notifications-settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
3
webroot/img/notification-bell.svg
Normal file
3
webroot/img/notification-bell.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.33333 16.0833H9.66667C9.66667 17 8.91667 17.75 8 17.75C7.08333 17.75 6.33333 17 6.33333 16.0833ZM15.5 14.4167V15.25H0.5V14.4167L2.16667 12.75V7.75C2.16667 5.16667 3.83333 2.91667 6.33333 2.16667V1.91667C6.33333 1 7.08333 0.25 8 0.25C8.91667 0.25 9.66667 1 9.66667 1.91667V2.16667C12.1667 2.91667 13.8333 5.16667 13.8333 7.75V12.75L15.5 14.4167ZM12.1667 7.75C12.1667 5.41667 10.3333 3.58333 8 3.58333C5.66667 3.58333 3.83333 5.41667 3.83333 7.75V13.5833H12.1667V7.75Z" fill="#343342"/>
|
||||
</svg>
|
After Width: | Height: | Size: 600 B |
BIN
webroot/img/owncast-background.png
Normal file
BIN
webroot/img/owncast-background.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 MiB |
|
@ -25,6 +25,8 @@ import FediverseFollowModal, {
|
|||
FediverseFollowButton,
|
||||
} from './components/fediverse-follow-modal.js';
|
||||
|
||||
import { NotifyButton, NotifyModal } from './components/notification.js';
|
||||
import { isPushNotificationSupported } from './notification/registerWeb.js';
|
||||
import {
|
||||
addNewlines,
|
||||
checkUrlPathForDisplay,
|
||||
|
@ -59,8 +61,10 @@ import {
|
|||
URL_STATUS,
|
||||
URL_VIEWER_PING,
|
||||
WIDTH_SINGLE_COL,
|
||||
USER_VISIT_COUNT_KEY,
|
||||
} from './utils/constants.js';
|
||||
import { checkIsModerator } from './utils/chat.js';
|
||||
|
||||
import TabBar from './components/tab-bar.js';
|
||||
|
||||
export default class App extends Component {
|
||||
|
@ -138,6 +142,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);
|
||||
|
@ -179,8 +185,22 @@ export default class App extends Component {
|
|||
});
|
||||
this.player.init();
|
||||
|
||||
this.registerServiceWorker();
|
||||
|
||||
// check routing
|
||||
this.getRoute();
|
||||
|
||||
// Increment the visit counter
|
||||
this.incrementVisitCounter();
|
||||
}
|
||||
|
||||
incrementVisitCounter() {
|
||||
let visits = parseInt(getLocalStorage(USER_VISIT_COUNT_KEY));
|
||||
if (isNaN(visits)) {
|
||||
visits = 0;
|
||||
}
|
||||
|
||||
setLocalStorage(USER_VISIT_COUNT_KEY, visits + 1);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
@ -248,7 +268,8 @@ export default class App extends Component {
|
|||
}
|
||||
|
||||
setConfigData(data = {}) {
|
||||
const { name, summary, chatDisabled, socketHostOverride } = data;
|
||||
const { name, summary, chatDisabled, socketHostOverride, notifications } =
|
||||
data;
|
||||
window.document.title = name;
|
||||
|
||||
this.socketHostOverride = socketHostOverride;
|
||||
|
@ -263,6 +284,7 @@ export default class App extends Component {
|
|||
|
||||
this.setState({
|
||||
canChat: !chatDisabled,
|
||||
notifications,
|
||||
configData: {
|
||||
...data,
|
||||
summary: summary && addNewlines(summary),
|
||||
|
@ -579,6 +601,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.
|
||||
|
@ -670,6 +709,7 @@ export default class App extends Component {
|
|||
|
||||
render(props, state) {
|
||||
const {
|
||||
accessToken,
|
||||
chatInputEnabled,
|
||||
configData,
|
||||
displayChatPanel,
|
||||
|
@ -690,6 +730,8 @@ export default class App extends Component {
|
|||
windowWidth,
|
||||
fediverseModalData,
|
||||
externalActionModalData,
|
||||
notificationModalData,
|
||||
notifications,
|
||||
lastDisconnectTime,
|
||||
section,
|
||||
sectionId,
|
||||
|
@ -753,6 +795,14 @@ export default class App extends Component {
|
|||
: html` <${VideoPoster} offlineImage=${logo} active=${streamOnline} /> `;
|
||||
|
||||
// modal buttons
|
||||
const notificationsButton =
|
||||
notifications &&
|
||||
notifications.browser.enabled &&
|
||||
isPushNotificationSupported() &&
|
||||
html`<${NotifyButton}
|
||||
serverName=${name}
|
||||
onClick=${this.displayNotificationModal}
|
||||
/>`;
|
||||
const externalActionButtons = html`<div
|
||||
id="external-actions-container"
|
||||
class="flex flex-row flex-wrap justify-end"
|
||||
|
@ -774,6 +824,7 @@ export default class App extends Component {
|
|||
federationInfo=${federation}
|
||||
serverName=${name}
|
||||
/>`}
|
||||
${notificationsButton}
|
||||
</div>`;
|
||||
|
||||
// modal component
|
||||
|
@ -800,6 +851,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}
|
||||
|
@ -807,7 +871,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}
|
||||
/>
|
||||
|
@ -977,6 +1041,7 @@ export default class App extends Component {
|
|||
</footer>
|
||||
|
||||
${chat} ${externalActionModal} ${fediverseFollowModal}
|
||||
${notificationModal}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
@ -46,11 +46,9 @@ export default class ChatMessageView extends Component {
|
|||
const { message, isModerator, accessToken } = this.props;
|
||||
const { user, timestamp } = message;
|
||||
|
||||
// User is required for this component to render.
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { displayName, displayColor, createdAt, isBot } = user;
|
||||
const isAuthorModerator = checkIsModerator(message);
|
||||
|
||||
|
|
|
@ -84,7 +84,7 @@ export default class ExternalActionModal extends Component {
|
|||
>
|
||||
<iframe
|
||||
id="external-modal-iframe"
|
||||
style=${iframeStyle}
|
||||
style=${{ iframeStyle }}
|
||||
class="bg-gray-100 bg-center bg-no-repeat"
|
||||
width="100%"
|
||||
allowpaymentrequest="true"
|
||||
|
|
405
webroot/js/components/notification.js
Normal file
405
webroot/js/components/notification.js
Normal file
|
@ -0,0 +1,405 @@
|
|||
import { h } from '/js/web_modules/preact.js';
|
||||
import { useState, useEffect } 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,
|
||||
URL_REGISTER_EMAIL_NOTIFICATION,
|
||||
HAS_DISPLAYED_NOTIFICATION_MODAL_KEY,
|
||||
USER_VISIT_COUNT_KEY,
|
||||
USER_DISMISSED_ANNOYING_NOTIFICATION_POPUP_KEY,
|
||||
} from '../utils/constants.js';
|
||||
import { setLocalStorage, getLocalStorage } from '../utils/helpers.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
export function NotifyModal({ notifications, streamName, accessToken }) {
|
||||
const [error, setError] = useState(null);
|
||||
const [loaderStyle, setLoaderStyle] = useState('none');
|
||||
const [emailNotificationsButtonEnabled, setEmailNotificationsButtonEnabled] =
|
||||
useState(false);
|
||||
const [emailAddress, setEmailAddress] = useState(null);
|
||||
const emailNotificationButtonState = emailNotificationsButtonEnabled
|
||||
? ''
|
||||
: 'cursor-not-allowed opacity-50';
|
||||
const [browserPushPermissionsPending, setBrowserPushPermissionsPending] =
|
||||
useState(false);
|
||||
|
||||
const { browser, email } = notifications;
|
||||
const { publicKey } = browser;
|
||||
|
||||
const browserPushEnabled = browser.enabled && isPushNotificationSupported();
|
||||
let emailEnabled = email.enabled;
|
||||
|
||||
// Store that the user has opened the notifications modal at least once
|
||||
// so we don't ever need to remind them to do it again.
|
||||
useEffect(() => {
|
||||
setLocalStorage(HAS_DISPLAYED_NOTIFICATION_MODAL_KEY, true);
|
||||
}, []);
|
||||
|
||||
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 startBrowserPushRegistration() {
|
||||
// If it's already denied or granted, don't do anything.
|
||||
if (Notification.permission !== 'default') {
|
||||
return;
|
||||
}
|
||||
|
||||
setBrowserPushPermissionsPending(true);
|
||||
try {
|
||||
const subscription = await registerWebPushNotifications(publicKey);
|
||||
saveNotificationRegistration('BROWSER_PUSH_NOTIFICATION', subscription);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(
|
||||
`Error registering for live notifications: ${e.message}. Make sure you're not inside a private browser environment or have previously disabled notifications for this stream.`
|
||||
);
|
||||
}
|
||||
setBrowserPushPermissionsPending(false);
|
||||
}
|
||||
|
||||
async function handlePushToggleChange() {
|
||||
// Nothing can be done if they already denied access.
|
||||
if (Notification.permission === 'denied') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pushEnabled) {
|
||||
startBrowserPushRegistration();
|
||||
}
|
||||
}
|
||||
|
||||
async function registerForEmailButtonPressed() {
|
||||
try {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ emailAddress: emailAddress }),
|
||||
};
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
URL_REGISTER_EMAIL_NOTIFICATION + `?accessToken=${accessToken}`,
|
||||
options
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(`Error registering for email notifications: ${e.message}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function onEmailInput(e) {
|
||||
const { value } = e.target;
|
||||
|
||||
// TODO: Add validation for email
|
||||
const valid = true;
|
||||
|
||||
setEmailAddress(value);
|
||||
setEmailNotificationsButtonEnabled(valid);
|
||||
}
|
||||
|
||||
function getBrowserPushButtonText() {
|
||||
let pushNotificationButtonText = html`<span id="push-notification-arrow"
|
||||
>←</span
|
||||
>
|
||||
CLICK TO ENABLE`;
|
||||
if (browserPushPermissionsPending) {
|
||||
pushNotificationButtonText = '↑ ACCEPT THE BROWSER PERMISSIONS';
|
||||
} else if (Notification.permission === 'granted') {
|
||||
pushNotificationButtonText = 'ENABLED';
|
||||
} else if (Notification.permission === 'denied') {
|
||||
pushNotificationButtonText = 'DENIED. PLEASE FIX BROWSER PERMISSIONS.';
|
||||
}
|
||||
return pushNotificationButtonText;
|
||||
}
|
||||
|
||||
const pushEnabled = Notification.permission === 'granted';
|
||||
|
||||
return html`
|
||||
<div class="bg-gray-100 bg-center bg-no-repeat p-6">
|
||||
<div
|
||||
style=${{ display: emailEnabled ? 'grid' : 'none' }}
|
||||
class="grid grid-cols-2 gap-10 px-5 py-8"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-slate-600 text-2xl mb-2 font-semibold">
|
||||
Email Notifications
|
||||
</h2>
|
||||
|
||||
<h2>
|
||||
Get notified directly to your email when this stream goes live.
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">Enter your email address:</div>
|
||||
<input
|
||||
class="border bg-white rounded-l w-8/12 mt-2 mb-1 mr-1 py-2 px-3 text-indigo-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
value=${emailAddress}
|
||||
onInput=${onEmailInput}
|
||||
placeholder="streamlover42@gmail.com"
|
||||
/>
|
||||
<button
|
||||
class="rounded-sm inline px-3 py-2 text-base text-white bg-indigo-700 ${emailNotificationButtonState}"
|
||||
onClick=${registerForEmailButtonPressed}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<div class="text-sm mt-3 text-gray-700">
|
||||
Stop receiving emails any time by clicking the unsubscribe link in
|
||||
the email. <a href="">Learn more.</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr
|
||||
style=${{ display: pushEnabled && emailEnabled ? 'block' : 'none' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-2 gap-10 px-5 py-8"
|
||||
style=${{ display: browserPushEnabled ? 'grid' : 'none' }}
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
class="text-sm border-2 p-4 border-red-300"
|
||||
style=${{
|
||||
display:
|
||||
Notification.permission === 'denied' ? 'block' : 'none',
|
||||
}}
|
||||
>
|
||||
Browser notification permissions were denied. Please visit your
|
||||
browser settings to re-enable in order to get notifications.
|
||||
</div>
|
||||
<div
|
||||
class="form-check form-switch"
|
||||
style=${{
|
||||
display:
|
||||
Notification.permission === 'denied' ? 'none' : 'block',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="relative inline-block w-10 align-middle select-none transition duration-200 ease-in"
|
||||
>
|
||||
<input
|
||||
checked=${pushEnabled || browserPushPermissionsPending}
|
||||
disabled=${pushEnabled}
|
||||
type="checkbox"
|
||||
name="toggle"
|
||||
id="toggle"
|
||||
onchange=${handlePushToggleChange}
|
||||
class="toggle-checkbox absolute block w-8 h-8 rounded-full bg-white border-4 appearance-none cursor-pointer"
|
||||
/>
|
||||
<label
|
||||
for="toggle"
|
||||
style=${{ width: '50px' }}
|
||||
class="toggle-label block overflow-hidden h-8 rounded-full bg-gray-300 cursor-pointer"
|
||||
></label>
|
||||
</div>
|
||||
<div class="ml-8 text-xs inline-block text-gray-700">
|
||||
${getBrowserPushButtonText()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="text-slate-600 text-2xl mt-4 mb-2 font-semibold">
|
||||
Browser Notifications
|
||||
</h2>
|
||||
<h2>
|
||||
Get notified right in the browser each time this stream goes live.
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="text-sm mt-3"
|
||||
style=${{ display: !pushEnabled ? 'none' : 'block' }}
|
||||
>
|
||||
To disable push notifications from ${window.location.hostname}
|
||||
${' '} access your browser permissions for this site and turn off
|
||||
notifications.
|
||||
<div style=${{ 'margin-top': '5px' }}>
|
||||
<a href="">Learn more.</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="browser-push-preview-box"
|
||||
class="w-full bg-white p-4 m-2 mt-4"
|
||||
style=${{ display: pushEnabled ? 'none' : 'block' }}
|
||||
>
|
||||
<div class="text-lg text-gray-700 ml-2 my-2">
|
||||
${window.location.toString()} wants to
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 my-2">
|
||||
<svg
|
||||
class="mr-3"
|
||||
style=${{ display: 'inline-block' }}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14 12.3333V13H2V12.3333L3.33333 11V7C3.33333 4.93333 4.68667 3.11333 6.66667 2.52667C6.66667 2.46 6.66667 2.4 6.66667 2.33333C6.66667 1.97971 6.80714 1.64057 7.05719 1.39052C7.30724 1.14048 7.64638 1 8 1C8.35362 1 8.69276 1.14048 8.94281 1.39052C9.19286 1.64057 9.33333 1.97971 9.33333 2.33333C9.33333 2.4 9.33333 2.46 9.33333 2.52667C11.3133 3.11333 12.6667 4.93333 12.6667 7V11L14 12.3333ZM9.33333 13.6667C9.33333 14.0203 9.19286 14.3594 8.94281 14.6095C8.69276 14.8595 8.35362 15 8 15C7.64638 15 7.30724 14.8595 7.05719 14.6095C6.80714 14.3594 6.66667 14.0203 6.66667 13.6667"
|
||||
fill="#676670"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
Show notifications
|
||||
</div>
|
||||
<div class="flex flex-row justify-end">
|
||||
<button
|
||||
class="bg-blue-500 py-1 px-4 mr-4 rounded-sm text-white"
|
||||
onClick=${startBrowserPushRegistration}
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
<button
|
||||
class="bg-slate-200 py-1 px-4 rounded-sm text-gray-500 cursor-not-allowed"
|
||||
style=${{
|
||||
'outline-width': 1,
|
||||
'outline-color': '#e2e8f0',
|
||||
'outline-style': 'solid',
|
||||
}}
|
||||
>
|
||||
Block
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="text-gray-700 text-sm mt-6"
|
||||
style=${{ display: pushEnabled ? 'none' : 'block' }}
|
||||
>
|
||||
You'll need to allow your browser to receive notifications from
|
||||
${' '} ${streamName}, first.
|
||||
</p>
|
||||
</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, onClick }) {
|
||||
const hasDisplayedNotificationModal = getLocalStorage(
|
||||
HAS_DISPLAYED_NOTIFICATION_MODAL_KEY
|
||||
);
|
||||
|
||||
const hasPreviouslyDismissedAnnoyingPopup = getLocalStorage(
|
||||
USER_DISMISSED_ANNOYING_NOTIFICATION_POPUP_KEY
|
||||
);
|
||||
|
||||
let visits = parseInt(getLocalStorage(USER_VISIT_COUNT_KEY));
|
||||
if (isNaN(visits)) {
|
||||
visits = 0;
|
||||
}
|
||||
|
||||
// Only show the annoying popup if the user has never opened the notification
|
||||
// modal previously _and_ they've visited more than 3 times.
|
||||
const [showPopup, setShowPopup] = useState(
|
||||
!hasPreviouslyDismissedAnnoyingPopup &&
|
||||
!hasDisplayedNotificationModal &&
|
||||
visits > 3
|
||||
);
|
||||
|
||||
const notifyAction = {
|
||||
color: 'rgba(219, 223, 231, 1)',
|
||||
description: `Never miss a stream! Get notified when ${serverName} goes live.`,
|
||||
icon: '/img/notification-bell.svg',
|
||||
openExternally: false,
|
||||
};
|
||||
|
||||
const buttonClicked = (e) => {
|
||||
onClick(e);
|
||||
setShowPopup(false);
|
||||
};
|
||||
|
||||
const notifyPopupDismissedClicked = () => {
|
||||
setShowPopup(false);
|
||||
setLocalStorage(USER_DISMISSED_ANNOYING_NOTIFICATION_POPUP_KEY, true);
|
||||
};
|
||||
|
||||
return html`
|
||||
<span id="notify-button-container" class="relative">
|
||||
<div
|
||||
id="follow-button-popup"
|
||||
style=${{ display: showPopup ? 'block' : 'none' }}
|
||||
>
|
||||
<svg
|
||||
width="192"
|
||||
height="113"
|
||||
viewBox="0 0 192 113"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8 0C3.58172 0 0 3.58172 0 8V91C0 95.4183 3.58173 99 8 99H172L188.775 112.001C190.089 113.019 192 112.082 192 110.42V99V8C192 3.58172 188.418 0 184 0H8Z"
|
||||
fill="#6965F0"
|
||||
/>
|
||||
<text x="20" y="55" fill="white" font-size="13px">
|
||||
Click and never miss
|
||||
</text>
|
||||
<text x="20" y="75" fill="white" font-size="13px">
|
||||
future streams.
|
||||
</text>
|
||||
</svg>
|
||||
<button
|
||||
class="absolute"
|
||||
style=${{ top: '6px', right: '6px' }}
|
||||
onClick=${notifyPopupDismissedClicked}
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17.7071 7.70711C18.0976 7.31658 18.0976 6.68342 17.7071 6.29289C17.3166 5.90237 16.6834 5.90237 16.2929 6.29289L12 10.5858L7.70711 6.29289C7.31658 5.90237 6.68342 5.90237 6.29289 6.29289C5.90237 6.68342 5.90237 7.31658 6.29289 7.70711L10.5858 12L6.29289 16.2929C5.90237 16.6834 5.90237 17.3166 6.29289 17.7071C6.68342 18.0976 7.31658 18.0976 7.70711 17.7071L12 13.4142L16.2929 17.7071C16.6834 18.0976 17.3166 18.0976 17.7071 17.7071C18.0976 17.3166 18.0976 16.6834 17.7071 16.2929L13.4142 12L17.7071 7.70711Z"
|
||||
fill="#A5A3F6"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<${ExternalActionButton}
|
||||
onClick=${buttonClicked}
|
||||
action=${notifyAction}
|
||||
/>
|
||||
</span>
|
||||
`;
|
||||
}
|
30
webroot/js/notification/registerWeb.js
Normal file
30
webroot/js/notification/registerWeb.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
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) {
|
||||
var padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
var base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
|
||||
|
||||
var rawData = window.atob(base64);
|
||||
var outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (var i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
|
@ -19,6 +19,9 @@ export const URL_CHAT_REGISTRATION = `/api/chat/register`;
|
|||
export const URL_FOLLOWERS = `/api/followers`;
|
||||
export const URL_PLAYBACK_METRICS = `/api/metrics/playback`;
|
||||
|
||||
export const URL_REGISTER_NOTIFICATION = `/api/notifications/register`;
|
||||
export const URL_REGISTER_EMAIL_NOTIFICATION = `/api/notifications/register/email`;
|
||||
|
||||
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;
|
||||
|
@ -65,3 +68,10 @@ export const WIDTH_SINGLE_COL = 780;
|
|||
export const HEIGHT_SHORT_WIDE = 500;
|
||||
export const ORIENTATION_PORTRAIT = 'portrait';
|
||||
export const ORIENTATION_LANDSCAPE = 'landscape';
|
||||
|
||||
// localstorage keys
|
||||
export const HAS_DISPLAYED_NOTIFICATION_MODAL_KEY =
|
||||
'HAS_DISPLAYED_NOTIFICATION_MODAL';
|
||||
export const USER_VISIT_COUNT_KEY = 'USER_VISIT_COUNT';
|
||||
export const USER_DISMISSED_ANNOYING_NOTIFICATION_POPUP_KEY =
|
||||
'USER_DISMISSED_ANNOYING_NOTIFICATION_POPUP_KEY';
|
||||
|
|
1
webroot/js/web_modules/import-map.json
vendored
1
webroot/js/web_modules/import-map.json
vendored
|
@ -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"
|
||||
|
|
5
webroot/js/web_modules/preact/hooks.js
Normal file
5
webroot/js/web_modules/preact/hooks.js
Normal file
|
@ -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(){for(var t;t=i.shift();)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 };
|
24
webroot/serviceWorker.js
Normal file
24
webroot/serviceWorker.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
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) => {
|
||||
clients.openWindow('/');
|
||||
});
|
|
@ -8,7 +8,7 @@ May have overrides for other components with own stylesheets.
|
|||
--header-height: 3.5em;
|
||||
--right-col-width: 24em;
|
||||
--video-container-height: calc((9 / 16) * 100vw);
|
||||
--header-bg-color: rgba(20,0,40,1);
|
||||
--header-bg-color: rgba(20, 0, 40, 1);
|
||||
--user-image-width: 10em;
|
||||
|
||||
--novideo-container-height: 16em;
|
||||
|
@ -51,7 +51,7 @@ a:hover {
|
|||
}
|
||||
|
||||
button[disabled] {
|
||||
opacity: .5;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
@ -66,7 +66,6 @@ button[disabled] {
|
|||
white-space: nowrap; /* added line */
|
||||
}
|
||||
|
||||
|
||||
header {
|
||||
height: var(--header-height);
|
||||
background-color: var(--header-bg-color);
|
||||
|
@ -94,7 +93,7 @@ header {
|
|||
box-shadow: var(--owncast-purple) 0px 0px 5px;
|
||||
}
|
||||
.external-action-icon {
|
||||
margin: .25em .5em .25em 0;
|
||||
margin: 0.25em 0.5em 0.25em 0;
|
||||
}
|
||||
.external-action-icon img {
|
||||
height: 1.5em;
|
||||
|
@ -122,7 +121,7 @@ header {
|
|||
background-size: 30%;
|
||||
}
|
||||
#video-container #video {
|
||||
transition: opacity .5s;
|
||||
transition: opacity 0.5s;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
@ -146,7 +145,7 @@ header {
|
|||
}
|
||||
|
||||
.chat-hidden #chat-toggle {
|
||||
opacity: .75;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* hide chat by default */
|
||||
|
@ -182,11 +181,10 @@ header {
|
|||
}
|
||||
|
||||
/* display `send` button on mobile */
|
||||
.touch-screen #send-message-button{
|
||||
.touch-screen #send-message-button {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
|
||||
/* *********** single col layout ***************************** */
|
||||
|
||||
.single-col {
|
||||
|
@ -245,7 +243,6 @@ header {
|
|||
max-height: 3em;
|
||||
}
|
||||
|
||||
|
||||
.single-col .user-logo-icons {
|
||||
margin-right: 0;
|
||||
margin-bottom: 1em;
|
||||
|
@ -264,7 +261,7 @@ header {
|
|||
margin-left: 1em;
|
||||
}
|
||||
.single-col .follow-icon-list {
|
||||
justify-content: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.single-col.use-fediverse-follow #fediverse-button-singlecol {
|
||||
display: inline-block;
|
||||
|
@ -274,7 +271,6 @@ header {
|
|||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
@media screen and (max-height: 500px) {
|
||||
.single-col.touch-screen:not(.touch-keyboard-active) {
|
||||
--header-height: 0px;
|
||||
|
@ -285,14 +281,12 @@ header {
|
|||
}
|
||||
/* ************************************************ */
|
||||
|
||||
|
||||
.no-video #video-container {
|
||||
min-height: var(--video-container-height);
|
||||
}
|
||||
|
||||
/* ************************************************ */
|
||||
|
||||
|
||||
@media screen and (max-width: 860px) {
|
||||
:root {
|
||||
--right-col-width: 20em;
|
||||
|
@ -307,7 +301,6 @@ header {
|
|||
|
||||
/* ************************************************ */
|
||||
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
#user-info-change {
|
||||
width: 75vw;
|
||||
|
@ -319,12 +312,10 @@ header {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
#external-modal-iframe {
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
|
||||
/**************************
|
||||
Basic Modal Styles
|
||||
**************************/
|
||||
|
@ -342,7 +333,7 @@ header {
|
|||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.75);
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
@ -353,9 +344,8 @@ header {
|
|||
position: relative;
|
||||
background-color: transparent;
|
||||
padding: 0px;
|
||||
max-width: 740px;
|
||||
min-width: 500px;
|
||||
width: 50%;
|
||||
max-width: 780px;
|
||||
width: 60%;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
@ -373,22 +363,25 @@ header {
|
|||
margin: 0px;
|
||||
padding: 0px;
|
||||
outline: none;
|
||||
cursor: pointer !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
.modal__close:before {
|
||||
content: '\2715';
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.modal__close:before { content: "\2715"; font-size: 1.25rem; }
|
||||
|
||||
@supports (display: flex) {
|
||||
.modal__header {
|
||||
.modal__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height:initial;
|
||||
}
|
||||
height: initial;
|
||||
}
|
||||
.modal__title {
|
||||
position: static;
|
||||
position: static;
|
||||
}
|
||||
.modal__close {
|
||||
position: static;
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -397,23 +390,39 @@ header {
|
|||
**************************/
|
||||
|
||||
@keyframes mmfadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmfadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideIn {
|
||||
from { transform: translateY(15%); }
|
||||
to { transform: translateY(0); }
|
||||
from {
|
||||
transform: translateY(15%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mmslideOut {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-10%); }
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
transform: translateY(-10%);
|
||||
}
|
||||
}
|
||||
|
||||
.micromodal-slide {
|
||||
|
@ -424,20 +433,20 @@ header {
|
|||
display: block;
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__overlay {
|
||||
animation: mmfadeIn .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
.micromodal-slide[aria-hidden='false'] .modal__overlay {
|
||||
animation: mmfadeIn 0.3s cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="false"] .modal__container {
|
||||
animation: mmslideIn .3s cubic-bezier(0, 0, .2, 1);
|
||||
.micromodal-slide[aria-hidden='false'] .modal__container {
|
||||
animation: mmslideIn 0.3s cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__overlay {
|
||||
animation: mmfadeOut .3s cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||
.micromodal-slide[aria-hidden='true'] .modal__overlay {
|
||||
animation: mmfadeOut 0.3s cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide[aria-hidden="true"] .modal__container {
|
||||
animation: mmslideOut .3s cubic-bezier(0, 0, .2, 1);
|
||||
.micromodal-slide[aria-hidden='true'] .modal__container {
|
||||
animation: mmslideOut 0.3s cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.micromodal-slide .modal__container,
|
||||
|
@ -446,40 +455,39 @@ header {
|
|||
}
|
||||
|
||||
/* Miromodal mobile styling */
|
||||
@media only screen and (min-device-width : 600px) and (max-device-width : 480px) {
|
||||
.modal__container {
|
||||
width: 90% !important;
|
||||
min-width: 90% !important;
|
||||
}
|
||||
@supports (display: flex) {
|
||||
.modal__container {
|
||||
width: 90% !important;
|
||||
min-width: 90% !important;
|
||||
@media only screen and (min-device-width: 600px) and (max-device-width: 480px) {
|
||||
.modal__container {
|
||||
width: 90% !important;
|
||||
min-width: 90% !important;
|
||||
}
|
||||
@supports (display: flex) {
|
||||
.modal__container {
|
||||
width: 90% !important;
|
||||
min-width: 90% !important;
|
||||
height: 85vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.modal__content {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**************************
|
||||
Tab Bar Base Styles
|
||||
**************************/
|
||||
.tab-bar [role="tab"] {
|
||||
padding: .5rem 1rem;
|
||||
border-radius: .25rem .25rem 0 0;
|
||||
.tab-bar [role='tab'] {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.25rem 0.25rem 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tab-bar [role="tab"]:hover {
|
||||
background-color: rgba(255,255,255,.35);
|
||||
.tab-bar [role='tab']:hover {
|
||||
background-color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.tab-bar [role="tab"][aria-selected="true"] {
|
||||
.tab-bar [role='tab'][aria-selected='true'] {
|
||||
color: var(--owncast-purple);
|
||||
background-color: white;
|
||||
}
|
||||
.tab-bar [role="tabpanel"] {
|
||||
.tab-bar [role='tabpanel'] {
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--header-bg-color);
|
||||
min-height: 15rem;
|
||||
|
@ -487,7 +495,7 @@ header {
|
|||
|
||||
.follower {
|
||||
width: 20vw;
|
||||
max-width: 300px
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.following-list-follower {
|
||||
|
@ -512,3 +520,60 @@ header {
|
|||
height: 100px;
|
||||
}
|
||||
|
||||
/* CHECKBOX TOGGLE SWITCH */
|
||||
/* @apply rules for documentation, these do not work as inline style */
|
||||
.toggle-checkbox:checked {
|
||||
/* @apply: right-0 #5A67D8; */
|
||||
left: 63%;
|
||||
/* border-color:var(--owncast-purple); */
|
||||
}
|
||||
|
||||
#toggle:checked {
|
||||
background-color: var(--owncast-purple);
|
||||
}
|
||||
|
||||
.toggle-checkbox:checked + .toggle-label {
|
||||
outline: 1px solid var(--owncast-purple);
|
||||
}
|
||||
|
||||
#browser-push-preview-box {
|
||||
outline-offset: 10px;
|
||||
outline: 2px dashed #acafb4;
|
||||
box-shadow: 2px 6px 7px 0px #87898d;
|
||||
}
|
||||
|
||||
#push-notification-arrow {
|
||||
position: relative;
|
||||
-webkit-animation: cubic-bezier(0.785, 0.135, 0.15, 0.86) infinite;
|
||||
-webkit-animation-name: left-right-bounce;
|
||||
-webkit-animation-duration: 1.5s;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
@-webkit-keyframes left-right-bounce {
|
||||
0% {
|
||||
left: 0;
|
||||
}
|
||||
50% {
|
||||
left: -6px;
|
||||
}
|
||||
100% {
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#notify-button-container #follow-button-popup {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
right: 30px;
|
||||
bottom: 42px;
|
||||
}
|
||||
|
||||
#notify-button-container .external-action-icon {
|
||||
margin: 0.25em 0.5em 0.25em 0.5em;
|
||||
}
|
||||
|
||||
#notify-button-container button {
|
||||
border-color: #b8bbc2;
|
||||
border-width: 1px;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue