diff --git a/pkg/config/enum.go b/pkg/config/enum.go index 2fa7c4b..63b33bd 100644 --- a/pkg/config/enum.go +++ b/pkg/config/enum.go @@ -142,6 +142,10 @@ const ( NotificationOptOutSubscriptions = "notif_optout_subscriptions" NotificationOptOutFriendRequestAccepted = "notif_optout_friend_request_accepted" NotificationOptOutPrivateGrant = "notif_optout_private_grant" + + // Web Push Notifications + PushNotificationOptOutMessage = "notif_optout_push_messages" + PushNotificationOptOutFriends = "notif_optout_push_friends" ) // Notification opt-outs (stored in ProfileField table) @@ -155,3 +159,9 @@ var NotificationOptOutFields = []string{ NotificationOptOutFriendRequestAccepted, NotificationOptOutPrivateGrant, } + +// Push Notification opt-outs (stored in ProfileField table) +var PushNotificationOptOutFields = []string{ + PushNotificationOptOutMessage, + PushNotificationOptOutFriends, +} diff --git a/pkg/config/variable.go b/pkg/config/variable.go index 576c8cd..3edad1b 100644 --- a/pkg/config/variable.go +++ b/pkg/config/variable.go @@ -9,6 +9,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/encryption/coldstorage" "code.nonshy.com/nonshy/website/pkg/encryption/keygen" "code.nonshy.com/nonshy/website/pkg/log" + "github.com/SherClockHolmes/webpush-go" "github.com/google/uuid" ) @@ -31,6 +32,7 @@ type Variable struct { BareRTC BareRTC Maintenance Maintenance Encryption Encryption + WebPush WebPush Turnstile Turnstile UseXForwardedFor bool } @@ -111,6 +113,19 @@ func LoadSettings() { writeSettings = true } + // Initialize the VAPID keys for Web Push Notification. + if len(Current.WebPush.VAPIDPublicKey) == 0 { + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + if err != nil { + log.Error("Initializing VAPID keys for Web Push: %s", err) + os.Exit(1) + } + + Current.WebPush.VAPIDPrivateKey = privateKey + Current.WebPush.VAPIDPublicKey = publicKey + writeSettings = true + } + // Have we added new config fields? Save the settings.json. if Current.Version != currentVersion || writeSettings { log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.") @@ -181,6 +196,12 @@ type Encryption struct { ColdStorageRSAPublicKey []byte } +// WebPush settings. +type WebPush struct { + VAPIDPublicKey string + VAPIDPrivateKey string +} + // Turnstile (Cloudflare CAPTCHA) settings. type Turnstile struct { Enabled bool diff --git a/pkg/controller/account/settings.go b/pkg/controller/account/settings.go index 187a375..2784bc5 100644 --- a/pkg/controller/account/settings.go +++ b/pkg/controller/account/settings.go @@ -276,6 +276,28 @@ func Settings() http.HandlerFunc { session.Flash(w, r, "Unsubscribed from all comment threads!") } } + case "push_notifications": + hashtag = "#notifications" + + // Store their notification opt-outs. + for _, key := range config.PushNotificationOptOutFields { + var value = r.PostFormValue(key) + + if value == "" { + value = "true" // opt-out, store opt-out=true in the DB + } else if value == "true" { + value = "false" // the box remained checked, they don't opt-out, store opt-out=false in the DB + } + + // Save it. + user.SetProfileField(key, value) + } + session.Flash(w, r, "Notification preferences updated!") + + // Save the user for new fields to be committed to DB. + if err := user.Save(); err != nil { + session.FlashError(w, r, "Failed to save user to database: %s", err) + } case "location": hashtag = "#location" var ( @@ -472,6 +494,9 @@ func Settings() http.HandlerFunc { // Count of subscribed comment threads. vars["SubscriptionCount"] = models.CountSubscriptions(user) + // Count of push notification subscriptions. + vars["PushNotificationsCount"] = models.CountPushNotificationSubscriptions(user) + if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/pkg/controller/friend/request.go b/pkg/controller/friend/request.go index c52a667..c4ba8f4 100644 --- a/pkg/controller/friend/request.go +++ b/pkg/controller/friend/request.go @@ -10,6 +10,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/templates" + "code.nonshy.com/nonshy/website/pkg/webpush" ) // AddFriend controller to send a friend request. @@ -122,6 +123,21 @@ func AddFriend() http.HandlerFunc { } else { // Log the change. models.LogCreated(currentUser, "friends", user.ID, "Sent a friend request to "+user.Username+".") + + // Send a push notification to the recipient. + go func() { + // Opted out of this one? + if user.GetProfileField(config.PushNotificationOptOutFriends) == "true" { + return + } + + log.Info("Try and send Web Push notification about new Friend Request to: %s", user.Username) + webpush.SendNotification(user, webpush.Payload{ + Topic: "friend", + Title: "New Friend Request!", + Body: fmt.Sprintf("%s wants to be your friend on %s.", currentUser.Username, config.Title), + }) + }() } session.Flash(w, r, "Friend request sent!") } diff --git a/pkg/controller/inbox/compose.go b/pkg/controller/inbox/compose.go index 94e6ce4..ae58f80 100644 --- a/pkg/controller/inbox/compose.go +++ b/pkg/controller/inbox/compose.go @@ -4,9 +4,12 @@ import ( "fmt" "net/http" + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/templates" + "code.nonshy.com/nonshy/website/pkg/webpush" ) // Compose a new chat coming from a user's profile page. @@ -61,9 +64,25 @@ func Compose() http.HandlerFunc { return } + // Send a push notification to the recipient. + go func() { + // Opted out of this one? + if user.GetProfileField(config.PushNotificationOptOutMessage) == "true" { + return + } + + log.Info("Try and send Web Push notification about new Message to: %s", user.Username) + webpush.SendNotification(user, webpush.Payload{ + Topic: "inbox", + Title: "New Message!", + Body: fmt.Sprintf("%s has left you a message on %s.", currentUser.Username, config.Title), + }) + }() + session.Flash(w, r, "Your message has been delivered!") if from == "inbox" { templates.Redirect(w, fmt.Sprintf("/messages/read/%d", m.ID)) + return } templates.Redirect(w, "/messages") return diff --git a/pkg/controller/index/index.go b/pkg/controller/index/index.go index a57b101..648eece 100644 --- a/pkg/controller/index/index.go +++ b/pkg/controller/index/index.go @@ -38,3 +38,12 @@ func Manifest() http.HandlerFunc { http.ServeFile(w, r, config.StaticPath+"/manifest.json") }) } + +// Service Worker for web push. +func ServiceWorker() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/javascript; charset=UTF-8") + w.Header().Add("Service-Worker-Allowed", "/") + http.ServeFile(w, r, config.StaticPath+"/js/service-worker.js") + }) +} diff --git a/pkg/models/deletion/delete_user.go b/pkg/models/deletion/delete_user.go index 771ea83..17f9451 100644 --- a/pkg/models/deletion/delete_user.go +++ b/pkg/models/deletion/delete_user.go @@ -55,6 +55,7 @@ func DeleteUser(user *models.User) error { {"User Notes", DeleteUserNotes}, {"Change Logs", DeleteChangeLogs}, {"IP Addresses", DeleteIPAddresses}, + {"Push Notifications", DeletePushNotifications}, } for _, item := range todo { if err := item.Fn(user.ID); err != nil { @@ -361,3 +362,13 @@ func DeleteIPAddresses(userID uint64) error { ).Delete(&models.IPAddress{}) return result.Error } + +// DeletePushNotifications scrubs data for deleting a user. +func DeletePushNotifications(userID uint64) error { + log.Error("DeleteUser: DeletePushNotifications(%d)", userID) + result := models.DB.Where( + "user_id = ?", + userID, + ).Delete(&models.PushNotification{}) + return result.Error +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 6647cc1..f04780e 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -33,4 +33,5 @@ func AutoMigrate() { DB.AutoMigrate(&TwoFactor{}) DB.AutoMigrate(&ChangeLog{}) DB.AutoMigrate(&IPAddress{}) + DB.AutoMigrate(&PushNotification{}) } diff --git a/pkg/models/notification_push.go b/pkg/models/notification_push.go new file mode 100644 index 0000000..421f718 --- /dev/null +++ b/pkg/models/notification_push.go @@ -0,0 +1,85 @@ +package models + +import ( + "time" + + "code.nonshy.com/nonshy/website/pkg/log" +) + +// PushNotification table for Web Push subscriptions. +type PushNotification struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` + Subscription string `gorm:"index"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// RegisterPushNotification stores a registration for the user. +func RegisterPushNotification(user *User, subscription string) (*PushNotification, error) { + // Check for an existing registration. + pn, err := GetPushNotificationFromSubscription(user, subscription) + if err == nil { + return pn, nil + } + + // Create it. + pn = &PushNotification{ + UserID: user.ID, + Subscription: subscription, + } + result := DB.Create(pn) + return pn, result.Error +} + +// GetPushNotificationFromSubscription checks for an existing subscription. +func GetPushNotificationFromSubscription(user *User, subscription string) (*PushNotification, error) { + var ( + pn *PushNotification + result = DB.Model(&PushNotification{}).Where( + "user_id = ? AND subscription = ?", + user.ID, subscription, + ).First(&pn) + ) + return pn, result.Error +} + +// CountPushNotificationSubscriptions returns how many subscriptions the user has for push. +func CountPushNotificationSubscriptions(user *User) int64 { + var count int64 + result := DB.Where( + "user_id = ?", + user.ID, + ).Model(&PushNotification{}).Count(&count) + if result.Error != nil { + log.Error("CountPushNotificationSubscriptions(%d): %s", user.ID, result.Error) + } + return count +} + +// GetPushNotificationSubscriptions returns all subscriptions for a user. +func GetPushNotificationSubscriptions(user *User) ([]*PushNotification, error) { + var ( + pn = []*PushNotification{} + result = DB.Model(&PushNotification{}).Where("user_id = ?", user.ID).Scan(&pn) + ) + return pn, result.Error +} + +// DeletePushNotifications scrubs data for deleting a user. +func DeletePushNotificationSubscriptions(user *User) error { + result := DB.Where( + "user_id = ?", + user.ID, + ).Delete(&PushNotification{}) + return result.Error +} + +// DeletePushNotification removes a single subscription from the database. +func DeletePushNotification(user *User, subscription string) error { + result := DB.Where( + "user_id = ? AND subscription = ?", + user.ID, subscription, + ).Delete(&PushNotification{}) + return result.Error +} diff --git a/pkg/router/router.go b/pkg/router/router.go index a5bb6ba..24b82fd 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -21,6 +21,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/controller/poll" "code.nonshy.com/nonshy/website/pkg/middleware" nst "code.nonshy.com/nonshy/website/pkg/templates" + "code.nonshy.com/nonshy/website/pkg/webpush" ) func New() http.Handler { @@ -30,6 +31,7 @@ func New() http.Handler { mux.HandleFunc("/", index.Create()) mux.HandleFunc("GET /favicon.ico", index.Favicon()) mux.HandleFunc("GET /manifest.json", index.Manifest()) + mux.HandleFunc("GET /sw.js", index.ServiceWorker()) mux.HandleFunc("GET /about", index.StaticTemplate("about.html")()) mux.HandleFunc("GET /features", index.StaticTemplate("features.html")()) mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")()) @@ -111,6 +113,9 @@ func New() http.Handler { mux.HandleFunc("GET /v1/version", api.Version()) mux.HandleFunc("GET /v1/users/me", api.LoginOK()) mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck()) + mux.HandleFunc("GET /v1/web-push/vapid-public-key", webpush.VAPIDPublicKey) + mux.Handle("POST /v1/web-push/register", middleware.LoginRequired(webpush.Register())) + mux.Handle("GET /v1/web-push/unregister", middleware.LoginRequired(webpush.UnregisterAll())) mux.Handle("POST /v1/likes", middleware.LoginRequired(api.Likes())) mux.Handle("GET /v1/likes/users", middleware.LoginRequired(api.WhoLikes())) mux.Handle("POST /v1/notifications/read", middleware.LoginRequired(api.ReadNotification())) diff --git a/pkg/webpush/webpush.go b/pkg/webpush/webpush.go new file mode 100644 index 0000000..2e1672d --- /dev/null +++ b/pkg/webpush/webpush.go @@ -0,0 +1,180 @@ +// Package webpush provides Web Push Notification functionality. +package webpush + +import ( + "encoding/json" + "fmt" + "net/http" + + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/controller/api" + "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" + webpush "github.com/SherClockHolmes/webpush-go" +) + +// VAPIDPublicKey returns the site's public key as an endpoint. +func VAPIDPublicKey(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(config.Current.WebPush.VAPIDPublicKey)) +} + +// UnregisterAll resets a user's stored push notification subscriptions. +func UnregisterAll() http.HandlerFunc { + var next = "/settings#notifications" + + return func(w http.ResponseWriter, r *http.Request) { + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "You must be logged in to do that!") + templates.Redirect(w, next) + return + } + + if err := models.DeletePushNotificationSubscriptions(currentUser); err != nil { + session.FlashError(w, r, "Error removing your subscriptions: %s", err) + } else { + session.Flash(w, r, "Your push notification subscriptions have been reset!") + } + templates.Redirect(w, next) + } +} + +// Register endpoint for push notification. +func Register() http.HandlerFunc { + type Request struct { + Endpoint string `json:"endpoint"` + ExpirationTime float64 `json:"expirationTime"` + Keys struct { + Auth string `json:"auth"` + P256DH string `json:"p256dh"` + } `json:"keys"` + } + + type Response struct { + OK bool `json:"OK"` + Error string `json:"error,omitempty"` + } + + return func(w http.ResponseWriter, r *http.Request) { + currentUser, err := session.CurrentUser(r) + if err != nil { + api.SendJSON(w, http.StatusUnauthorized, Response{ + Error: "You must be logged in to do that!", + }) + return + } + + // Parse request payload. + var req Request + if err := api.ParseJSON(r, &req); err != nil { + api.SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error with request payload: %s", err), + }) + return + } + + // Validate it looked correct. + if req.Endpoint == "" || req.Keys.Auth == "" || req.Keys.P256DH == "" { + api.SendJSON(w, http.StatusBadRequest, Response{ + Error: "Subscription fields were missing.", + }) + return + } + + // Serialize and store it in the database. + buf, err := json.Marshal(req) + if err != nil { + api.SendJSON(w, http.StatusInternalServerError, Response{ + Error: "Couldn't reserialize your subscription!", + }) + return + } + + _, err = models.RegisterPushNotification(currentUser, string(buf)) + if err != nil { + api.SendJSON(w, http.StatusInternalServerError, Response{ + Error: "Couldn't create the registration in the database!", + }) + return + } + + api.SendJSON(w, http.StatusCreated, Response{ + OK: true, + }) + } +} + +// Payload sent in push notifications. +type Payload struct { + Topic string `json:"-"` + Title string `json:"title"` + Body string `json:"body"` +} + +// SendNotification sends a push notification to a user, broadcast to all of their subscriptions. +func SendNotification(user *models.User, body Payload) error { + // Send to all of their subscriptions. + subs, err := models.GetPushNotificationSubscriptions(user) + if err != nil { + return err + } + + payload, err := json.Marshal(body) + if err != nil { + return err + } + + for _, sub := range subs { + if err := SendRawNotification(user, payload, body.Topic, sub.Subscription); err != nil { + log.Error("SendNotification: %s", err) + } + } + + return nil +} + +// SendNotificationToSubscription sends to a specific push subscriber. +func SendNotificationToSubscription(user *models.User, subscription string, body Payload) error { + payload, err := json.Marshal(body) + if err != nil { + return err + } + + return SendRawNotification(user, payload, body.Topic, subscription) +} + +// SendRawNotification sends out a push message. +func SendRawNotification(user *models.User, message []byte, topic, subscription string) error { + // Decode the subscription. + var ( + s = &webpush.Subscription{} + err = json.Unmarshal([]byte(subscription), s) + options = &webpush.Options{ + Topic: topic, + Subscriber: user.Email, + VAPIDPublicKey: config.Current.WebPush.VAPIDPublicKey, + VAPIDPrivateKey: config.Current.WebPush.VAPIDPrivateKey, + TTL: 30, + } + ) + if err != nil { + return err + } + + resp, err := webpush.SendNotification(message, s, options) + if err != nil { + return fmt.Errorf("webpush.SendNotification: %s", err) + } + defer resp.Body.Close() + + // Handle error response codes. + if resp.StatusCode >= 400 { + log.Error("Got StatusCode %d when sending push notification; removing the subscription from DB", resp.StatusCode) + models.DeletePushNotification(user, subscription) + } + + return err +} diff --git a/web/static/img/favicon-128.png b/web/static/img/favicon-128.png new file mode 100644 index 0000000..3baf6b9 Binary files /dev/null and b/web/static/img/favicon-128.png differ diff --git a/web/static/img/site-settings.png b/web/static/img/site-settings.png new file mode 100644 index 0000000..7e19864 Binary files /dev/null and b/web/static/img/site-settings.png differ diff --git a/web/static/js/service-worker.js b/web/static/js/service-worker.js new file mode 100644 index 0000000..6a28e44 --- /dev/null +++ b/web/static/js/service-worker.js @@ -0,0 +1,19 @@ +/* nonshy service worker, for web push notifications */ + +self.addEventListener('install', (event) => { + self.skipWaiting(); +}); + +self.addEventListener('push', (event) => { + const payload = JSON.parse(event.data.text()); + try { + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + icon: "/static/img/favicon-192.png", + }) + ); + } catch(e) { + console.error("sw.showNotification:", e); + } +}); diff --git a/web/static/js/web-push.js b/web/static/js/web-push.js new file mode 100644 index 0000000..616c7ce --- /dev/null +++ b/web/static/js/web-push.js @@ -0,0 +1,45 @@ +/* nonshy web push notification helper */ +navigator.serviceWorker.register("/sw.js", { + scope: "/", +}).catch(err => { + console.error("Service Worker NOT registered:", err); +}); + +function PushNotificationSubscribe() { + navigator.serviceWorker.ready.then(async function(registration) { + return registration.pushManager.getSubscription().then(async function(subscription) { + // If a subscription was already found, return it. + if (subscription) { + return subscription; + } + + // Get the server's public key. + const response = await fetch("/v1/web-push/vapid-public-key"); + const vapidPublicKey = await response.text(); + + // Subscribe the user. + return registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidPublicKey, + }); + }).then(subscription => { + + // Post it to the backend. + const serialized = JSON.stringify(subscription); + fetch("/v1/web-push/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: serialized + }); + }); + }); +} + +// If the user has already given notification permission, (re)subscribe for push. +document.addEventListener("DOMContentLoaded", e => { + if (Notification.permission === "granted") { + PushNotificationSubscribe(); + } +}); diff --git a/web/templates/account/settings.html b/web/templates/account/settings.html index ba7d67c..6818ef5 100644 --- a/web/templates/account/settings.html +++ b/web/templates/account/settings.html @@ -930,188 +930,279 @@ -
- - -
-

- On this page you may opt-out of certain kinds of (on-site) notification messages. - {{PrettyTitle}} does not send you any e-mails or push notification -- these on-site - notifications only appear while you are visiting the website (on your - home/user dashboard page). -

- -
- {{InputCSRF}} - - -

New Photo Uploads

+
+
+ +

- By default you will be notified when your friends upload a new picture to the site. - Below, you may opt-out of new photo upload notifications. + You may opt-in to receive Web Push Notifications for some of your important updates + from {{PrettyTitle}}, such as when you receive a new Direct Message on the main website, + even when you have closed your browser.

-
- - -

- If unchecked, the following two notifications will not be sent either. +

+ Push Notification Permission: + + + +

+ +

+ + + Test Notifications + + + + +

+ + + {{if .PushNotificationsCount}} +

+ Sessions: you have enabled push notifications on {{.PushNotificationsCount}} web browser{{Pluralize64 .PushNotificationsCount}}. You may + click here to reset your subscriptions. Devices that you actively use + (and had granted permission on before) may re-subscribe on your next visit. +

+ {{end}} + + + + {{InputCSRF}} + + +
+ + +
+
+ +
+ +
+ +
+ + +
+
+ +
+ + +
+

+ On this page you may opt-out of certain kinds of (on-site) notification messages. + {{PrettyTitle}} does not send you any e-mails or push notification -- these on-site + notifications only appear while you are visiting the website (on your + home/user dashboard page). +

+ +
+ {{InputCSRF}} + + +

New Photo Uploads

+ +

+ By default you will be notified when your friends upload a new picture to the site. + Below, you may opt-out of new photo upload notifications.

-
-
- -
+
+ + +

+ If unchecked, the following two notifications will not be sent either. +

+
-
- -

- This will also depend on your opt-in to see explicit content -- otherwise - notifications about explicit photo uploads will not be sent to you. +

+ +
+ +
+ +

+ This will also depend on your opt-in to see explicit content -- otherwise + notifications about explicit photo uploads will not be sent to you. +

+
+ +

Likes & Comments

+ +

+ By default you will be notified when somebody 'likes' or comments on your profile page + or photos. You may turn off those notifications with the options below.

-
-

Likes & Comments

+
+ + +
-

- By default you will be notified when somebody 'likes' or comments on your profile page - or photos. You may turn off those notifications with the options below. -

+
+ +
-
- - -
+

Comment Thread Subscriptions

-
- -
- -

Comment Thread Subscriptions

- -

- Comment threads and forum posts may be 'subscribed' to so that you can be notified about - comments left by other people after you. By default, you will subscribe to comment threads - after you leave your first comment. -

- -

- Note: you may unsubscribe from comment threads by using the link at the - top of its page (for example: at the top of a forum thread page or the top of the list of - comments on a photo page). You may also opt in to get notifications on a thread - that you didn't comment on by using the same link at the top of their pages. -

- -

- The options below can control the automatic opt-in for subscriptions when you leave a - comment on a new comment thread. -

- -
- -
- -
- - -

- You are currently subscribed to {{.SubscriptionCount}} comment thread{{Pluralize64 .SubscriptionCount}}. - You may immediately unsubscribe from all of these threads by checking this box and clicking "Save" below. +

+ Comment threads and forum posts may be 'subscribed' to so that you can be notified about + comments left by other people after you. By default, you will subscribe to comment threads + after you leave your first comment.

-
-

Miscellaneous

- -
- - -
- -
- -
- - -
- -

- This notification is important for your account status, is rarely sent out, and can - not be opted-out from. +

+ Note: you may unsubscribe from comment threads by using the link at the + top of its page (for example: at the top of a forum thread page or the top of the list of + comments on a photo page). You may also opt in to get notifications on a thread + that you didn't comment on by using the same link at the top of their pages.

-
-
- -
+

+ The options below can control the automatic opt-in for subscriptions when you leave a + comment on a new comment thread. +

- +
+ +
+ +
+ + +

+ You are currently subscribed to {{.SubscriptionCount}} comment thread{{Pluralize64 .SubscriptionCount}}. + You may immediately unsubscribe from all of these threads by checking this box and clicking "Save" below. +

+
+ +

Miscellaneous

+ +
+ + +
+ +
+ +
+ + +
+ +

+ This notification is important for your account status, is rarely sent out, and can + not be opted-out from. +

+
+ +
+ +
+ + +
@@ -1143,8 +1234,8 @@

Your Two-Factor is currently: {{if .TwoFactorEnabled}} - - Enabled + + Enabled {{else}} Not Enabled @@ -1462,6 +1553,59 @@ window.addEventListener("DOMContentLoaded", (event) => { }); }); +// Notifications tab scripts. +window.addEventListener("DOMContentLoaded", (event) => { + // Get useful controls from the tab. + const $pushStatusGranted = document.querySelector("#push-status-enabled"), + $pushStatusDenied = document.querySelector("#push-status-disabled"), + $pushStatusDefault = document.querySelector("#push-status-default"), + $pushEnableButton = document.querySelector("#grant-push-permission"), + $pushDeniedHelp = document.querySelector("#push-denied-help"); + + // Get the current permission status: default, granted, denied. + const showPermission = (permission) => { + $pushStatusGranted.style.display = "none"; + $pushStatusDenied.style.display = "none"; + $pushStatusDefault.style.display = "none"; + switch (permission) { + case "granted": + $pushStatusGranted.style.display = ""; + $pushEnableButton.innerHTML = "Test Push Notification"; + break; + case "denied": + $pushEnableButton.style.display = "none"; + $pushDeniedHelp.style.display = ""; + $pushStatusDenied.style.display = ""; + break; + default: + $pushStatusDefault.style.display = ""; + $pushEnableButton.innerHTML = "Grant Push Notification Permission"; + break; + } + }; + showPermission(Notification.permission); + + $pushEnableButton.addEventListener("click", (e) => { + e.preventDefault(); + Notification.requestPermission().then(permission => { + // Update the displayed permission status. + showPermission(Notification.permission); + + // If granted, subscribe to push notifications now. + if (permission === "granted") { + // In static/js/web-push.js + PushNotificationSubscribe(); + + // Test the notification now. + const notification = new Notification(`Hello from ${document.location.hostname}!`, { + body: "This is an example notification from this site.", + icon: "/static/img/favicon-192.png", + }); + } + }); + }); +}); + // Location tab scripts. window.addEventListener("DOMContentLoaded", (event) => { // Get useful controls from the tab. diff --git a/web/templates/base.html b/web/templates/base.html index e13a305..e4c0be9 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -387,6 +387,7 @@ + {{template "scripts" .}} diff --git a/web/templates/faq.html b/web/templates/faq.html index 3f3b268..e91fddc 100644 --- a/web/templates/faq.html +++ b/web/templates/faq.html @@ -70,10 +70,19 @@

+
  • + Notification FAQs + +
  • Shy Account FAQs
      @@ -908,56 +917,75 @@

      What are the technical requirements to use the chat room?

      - The chat room seems to work the best on the following combination of devices and - web browsers: + The chat room should generally work well on all major web browsers, operating systems + and device types. Recommended browsers include Mozilla Firefox, Google Chrome (or any + other Chromium-based browser of your choice, such as Microsoft Edge, Opera or Brave), + or Apple's Safari browser. Most Androids, iPads and iPhones should be able to use the + chat room successfully, including webcam support.

      -
        -
      • - Firefox and Chromium-based web browsers - on all desktop-like operating systems including Windows, Mac OS - and Linux. Chromium-based browsers include Google Chrome, Microsoft Edge, Opera, - Brave or others that are based on the open source Chromium browser. -
      • -
      • - On Android devices, all Firefox and Chromium-based - browsers are generally working quite well. -
      • -
      -

      - The chat room and video sharing generally works well on the above devices. Below - are some that are known to have issues with the chat room at this time: + When opening many webcams: the number of cameras you can watch at a + time is mainly limited by your device's hardware specifications and your local network + bandwidth. The chat room doesn't enforce an arbitrary limit to the number of cameras, + so you can experiment and find out how many your device can support.

      -
        -
      • - Safari on Mac OS X usually works for the text chat portions - (entering the room and chatting), but webcam sharing doesn't seem to work (either - broadcasting or viewing others' cameras). -
      • -
      • - iPhone and iPad (all web browsers) have difficulty logging in to - the chat room at all. Chrome or Firefox do not work on iOS, either - because under - the hood all web browsers on iOS are just custom wrappers around Mobile Safari, - which doesn't like my chat room right now. So unfortunately, Apple mobile devices - can not use the chat room. -
      • -
      +

      + For some examples: a Macbook Air M3 laptop from 2024 is able to comfortably open more + than 20 webcams at a time. However, a Dell XPS 13 laptop from 2018 (which had 16GB RAM, + an nVIDIA graphics card, etc.) was seen to only be able to open 10 or 15 cameras before + the laptop became very warm. +

      -

      I can't share or connect to other peoples' webcams

      +

      I am experiencing a problem with webcam sharing

      - First, verify that you're using a known supported device + Please see the Webcam Troubleshooting + thread in the forums for some advice on things to try. +

      + +

      + Verify that you're using a known supported device when accessing the chat room. Generally this means you're running a Firefox or - Chromium-based browser on a desktop computer, laptop, or Android device. Webcam - sharing does not work at all in Safari on Mac OS, and portable - Apple devices such as iPad and iPhone do not work with the chat room at all. + Chromium-based browser on a desktop computer, laptop, or Android device. The Apple + Safari browser should also work from a Mac, iPhone or iPad computer. +

      + +

      + The most common type of error message people encounter on chat looks like: + NotAllowedError: Permission denied. This error message usually has + one of three causes: +

      + +
        +
      1. + Your web browser has denied permission to chat.nonshy.com, and you should check in + your web browser's settings (Privacy & Security section) for Webcam and Microphone + and remove chat.nonshy.com from the list of sites. After doing so, restart your browser + and log onto the chat room, and be sure to click "Allow" when it asks for permission + when going on webcam. +
      2. +
      3. + Sometimes, your operating system itself is actually denying permission to your web + browser. You can check in your System Settings for App Permissions for your webcam + and microphone, and ensure that your web browser has this permission on your device. +
      4. +
      5. + Sometimes this error comes up when your webcam is already in use by another + application (such as Skype or Discord). Ensure that there is no other app currently + using your webcam before you go on camera in the chat room. +
      6. +
      + +

      + The linked Webcam Troubleshooting thread above has instructions for common web browsers + and operating systems on where to check for permission errors.

      If you are on a supported device, check out the following information about how - webcam sharing works: + webcam sharing works in general:

        @@ -1017,15 +1045,6 @@
      -

      - If all else fails, it's unfortunate but at least the text chat functions should work at least. - Some big video apps like Zoom or Jitsi Meet tend to work better because, if you can't establish - a peer-to-peer connection, they will fall back on using a proxy server to transmit your video - through to the other party. The {{PrettyTitle}} chat room does not have such a proxy server -- - because the bandwidth can get expensive to carry video across! Only peer-to-peer video sharing - is supported at this time. -

      -

      Where can I learn more about the chat room?

      @@ -1033,6 +1052,85 @@ a tour of the chat room interface and some additional information about how to use the chat room.

      +

      Notification FAQs

      + +

      Does nonshy send me notifications?

      + +

      + Most {{PrettyTitle}} notifications are "on-site only" by default, meaning you need to log onto + the website to see them. We send very few e-mails from this website, ever: only for + your new account verification e-mail, certification photo approval, and when you forgot your password. +

      + +

      + The on-site notifications include things like when your friends upload a new picture, or when somebody + comments on or likes something you posted. You can manage your {{PrettyTitle}} notifications on your + Notification Settings page, to opt in or out of any of these. +

      + +

      + We have optional Web Push Notifications that you may enable so you can + know when somebody has left you a message or a friend request on the website. +

      + +

      About Web Push Notifications

      + +

      + If you would like to enable timely notifications when you receive a new Message or Friend Request + on the website, you may enable Web Push Notifications in your + settings. +

      + +

      + Currently, only a small subset of the site notifications can be sent via push notification: when you + get a new Direct Message or Friend Request. You may opt either of those out, in case you only care to + be notified about messages but not friend requests. +

      + +

      + All your other notifications (likes, comments, etc.) are still "on-site only" so you will need to log in + and check your Dashboard Page to catch up on those. +

      + +

      How do I turn off Web Push Notifications?

      + +

      + The easiest way to turn these off is to revoke your Notifications permission given to the nonshy website. +

      + +

      + On Firefox, Chrome and Chrome-like web browsers: in your URL address bar there should be a button to the + left of address which you can click on and see permissions for the website, where the "Notification" permission + can be easily revoked. +

      + + A screenshot of the Google Chrome settings drop-down near the address bar. + +

      + On other web browsers (or on mobile) you may need to go into your browser's settings. Under a section for + "Websites" (maybe under "Privacy & Security"), find the {{PrettyTitle}} website or the Notifications permission + and you can change your setting there. +

      + +

      Troubleshooting Web Push Notifications

      + +

      + When you first enable Web Push Notifications, your web browser should have prompted you for permission. In case + you have clicked "Deny" or "Never Allow," your web browser remembers your decision and the website is not allowed + to ask again. +

      + +

      + When this happens, the Notification Settings page will say that you have "Denied" + notification permission. +

      + +

      + In case you want to undo this: you will need to change or reset your Notification permission for this website. Please + see the previous answer for places to look. After you have reset your Notification permission, + refresh the Notification Settings page and try enabling notifications again. +

      +

      Shy Account FAQs

      diff --git a/web/templates/inbox/inbox.html b/web/templates/inbox/inbox.html index b266fdd..e0db5f9 100644 --- a/web/templates/inbox/inbox.html +++ b/web/templates/inbox/inbox.html @@ -207,6 +207,13 @@ other column to read the conversation here.

      + + +

      Pro Tip: @@ -335,3 +342,14 @@

  • {{end}} +{{define "scripts"}} + +{{end}} \ No newline at end of file