Web Push Notifications

* Add support for Web Push Notifications when users receive a new Message or
  Friend Request on the main website.
* Users opt in or out of this on their Notification Settings. They can also
  individually opt out of Message and Friend Request push notifications.
This commit is contained in:
Noah Petherbridge 2024-07-20 19:44:22 -07:00
parent dbeb5060e4
commit a314aab7ec
19 changed files with 921 additions and 214 deletions

View File

@ -142,6 +142,10 @@ const (
NotificationOptOutSubscriptions = "notif_optout_subscriptions" NotificationOptOutSubscriptions = "notif_optout_subscriptions"
NotificationOptOutFriendRequestAccepted = "notif_optout_friend_request_accepted" NotificationOptOutFriendRequestAccepted = "notif_optout_friend_request_accepted"
NotificationOptOutPrivateGrant = "notif_optout_private_grant" 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) // Notification opt-outs (stored in ProfileField table)
@ -155,3 +159,9 @@ var NotificationOptOutFields = []string{
NotificationOptOutFriendRequestAccepted, NotificationOptOutFriendRequestAccepted,
NotificationOptOutPrivateGrant, NotificationOptOutPrivateGrant,
} }
// Push Notification opt-outs (stored in ProfileField table)
var PushNotificationOptOutFields = []string{
PushNotificationOptOutMessage,
PushNotificationOptOutFriends,
}

View File

@ -9,6 +9,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/encryption/coldstorage" "code.nonshy.com/nonshy/website/pkg/encryption/coldstorage"
"code.nonshy.com/nonshy/website/pkg/encryption/keygen" "code.nonshy.com/nonshy/website/pkg/encryption/keygen"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"github.com/SherClockHolmes/webpush-go"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -31,6 +32,7 @@ type Variable struct {
BareRTC BareRTC BareRTC BareRTC
Maintenance Maintenance Maintenance Maintenance
Encryption Encryption Encryption Encryption
WebPush WebPush
Turnstile Turnstile Turnstile Turnstile
UseXForwardedFor bool UseXForwardedFor bool
} }
@ -111,6 +113,19 @@ func LoadSettings() {
writeSettings = true 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. // Have we added new config fields? Save the settings.json.
if Current.Version != currentVersion || writeSettings { if Current.Version != currentVersion || writeSettings {
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.") 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 ColdStorageRSAPublicKey []byte
} }
// WebPush settings.
type WebPush struct {
VAPIDPublicKey string
VAPIDPrivateKey string
}
// Turnstile (Cloudflare CAPTCHA) settings. // Turnstile (Cloudflare CAPTCHA) settings.
type Turnstile struct { type Turnstile struct {
Enabled bool Enabled bool

View File

@ -276,6 +276,28 @@ func Settings() http.HandlerFunc {
session.Flash(w, r, "Unsubscribed from all comment threads!") 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": case "location":
hashtag = "#location" hashtag = "#location"
var ( var (
@ -472,6 +494,9 @@ func Settings() http.HandlerFunc {
// Count of subscribed comment threads. // Count of subscribed comment threads.
vars["SubscriptionCount"] = models.CountSubscriptions(user) vars["SubscriptionCount"] = models.CountSubscriptions(user)
// Count of push notification subscriptions.
vars["PushNotificationsCount"] = models.CountPushNotificationSubscriptions(user)
if err := tmpl.Execute(w, r, vars); err != nil { if err := tmpl.Execute(w, r, vars); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return

View File

@ -10,6 +10,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates" "code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/webpush"
) )
// AddFriend controller to send a friend request. // AddFriend controller to send a friend request.
@ -122,6 +123,21 @@ func AddFriend() http.HandlerFunc {
} else { } else {
// Log the change. // Log the change.
models.LogCreated(currentUser, "friends", user.ID, "Sent a friend request to "+user.Username+".") 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!") session.Flash(w, r, "Friend request sent!")
} }

View File

@ -4,9 +4,12 @@ import (
"fmt" "fmt"
"net/http" "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/models"
"code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/session"
"code.nonshy.com/nonshy/website/pkg/templates" "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. // Compose a new chat coming from a user's profile page.
@ -61,9 +64,25 @@ func Compose() http.HandlerFunc {
return 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!") session.Flash(w, r, "Your message has been delivered!")
if from == "inbox" { if from == "inbox" {
templates.Redirect(w, fmt.Sprintf("/messages/read/%d", m.ID)) templates.Redirect(w, fmt.Sprintf("/messages/read/%d", m.ID))
return
} }
templates.Redirect(w, "/messages") templates.Redirect(w, "/messages")
return return

View File

@ -38,3 +38,12 @@ func Manifest() http.HandlerFunc {
http.ServeFile(w, r, config.StaticPath+"/manifest.json") 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")
})
}

View File

@ -55,6 +55,7 @@ func DeleteUser(user *models.User) error {
{"User Notes", DeleteUserNotes}, {"User Notes", DeleteUserNotes},
{"Change Logs", DeleteChangeLogs}, {"Change Logs", DeleteChangeLogs},
{"IP Addresses", DeleteIPAddresses}, {"IP Addresses", DeleteIPAddresses},
{"Push Notifications", DeletePushNotifications},
} }
for _, item := range todo { for _, item := range todo {
if err := item.Fn(user.ID); err != nil { if err := item.Fn(user.ID); err != nil {
@ -361,3 +362,13 @@ func DeleteIPAddresses(userID uint64) error {
).Delete(&models.IPAddress{}) ).Delete(&models.IPAddress{})
return result.Error 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
}

View File

@ -33,4 +33,5 @@ func AutoMigrate() {
DB.AutoMigrate(&TwoFactor{}) DB.AutoMigrate(&TwoFactor{})
DB.AutoMigrate(&ChangeLog{}) DB.AutoMigrate(&ChangeLog{})
DB.AutoMigrate(&IPAddress{}) DB.AutoMigrate(&IPAddress{})
DB.AutoMigrate(&PushNotification{})
} }

View File

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

View File

@ -21,6 +21,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/controller/poll" "code.nonshy.com/nonshy/website/pkg/controller/poll"
"code.nonshy.com/nonshy/website/pkg/middleware" "code.nonshy.com/nonshy/website/pkg/middleware"
nst "code.nonshy.com/nonshy/website/pkg/templates" nst "code.nonshy.com/nonshy/website/pkg/templates"
"code.nonshy.com/nonshy/website/pkg/webpush"
) )
func New() http.Handler { func New() http.Handler {
@ -30,6 +31,7 @@ func New() http.Handler {
mux.HandleFunc("/", index.Create()) mux.HandleFunc("/", index.Create())
mux.HandleFunc("GET /favicon.ico", index.Favicon()) mux.HandleFunc("GET /favicon.ico", index.Favicon())
mux.HandleFunc("GET /manifest.json", index.Manifest()) 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 /about", index.StaticTemplate("about.html")())
mux.HandleFunc("GET /features", index.StaticTemplate("features.html")()) mux.HandleFunc("GET /features", index.StaticTemplate("features.html")())
mux.HandleFunc("GET /faq", index.StaticTemplate("faq.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/version", api.Version())
mux.HandleFunc("GET /v1/users/me", api.LoginOK()) mux.HandleFunc("GET /v1/users/me", api.LoginOK())
mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck()) 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("POST /v1/likes", middleware.LoginRequired(api.Likes()))
mux.Handle("GET /v1/likes/users", middleware.LoginRequired(api.WhoLikes())) mux.Handle("GET /v1/likes/users", middleware.LoginRequired(api.WhoLikes()))
mux.Handle("POST /v1/notifications/read", middleware.LoginRequired(api.ReadNotification())) mux.Handle("POST /v1/notifications/read", middleware.LoginRequired(api.ReadNotification()))

180
pkg/webpush/webpush.go Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

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

45
web/static/js/web-push.js Normal file
View File

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

View File

@ -930,7 +930,97 @@
</div> </div>
<!-- Notification Settings --> <!-- Notification Settings -->
<div class="card mb-5" id="notifications"> <div id="notifications">
<div class="card mb-5">
<header class="card-header has-background-link">
<p class="card-header-title has-text-light">
<i class="fa fa-bell pr-2"></i>
Web Push Notifications <span class="tag is-success ml-2">New!</span>
</p>
</header>
<div class="card-content">
<p class="block">
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.
</p>
<p class="block">
<strong>Push Notification Permission:</strong>
<strong class="has-text-success" id="push-status-enabled" style="display: none">
<i class="fa fa-check mr-1"></i> Granted
</strong>
<strong class="has-text-danger" id="push-status-disabled" style="display: none">
<i class="fa fa-xmark mr-1"></i> Denied
</strong>
<strong class="has-text-warning" id="push-status-default" style="display: none">
<i class="fa fa-xmark mr-1"></i> Not Granted
</strong>
</p>
<p class="block">
<!-- Button to Grant Permission or Test Notifications -->
<a href="#" id="grant-push-permission"
class="button is-small is-success">
Test Notifications
</a>
<!-- Help error if the permission is denied -->
<span id="push-denied-help" style="display: none">
<i class="fa fa-info-circle has-text-warning mr-1"></i>
You had denied notification permission to this site. Please check in your web browser's
settings (or click in your address bar to the left of the website URL) to reset your
permission setting. Please <a href="/faq#troubleshoot-web-push">see this page</a> for
help in case you want to resolve this.
</span>
</p>
<!-- Have existing subscriptions? -->
{{if .PushNotificationsCount}}
<p class="block">
<strong>Sessions:</strong> you have enabled push notifications on {{.PushNotificationsCount}} web browser{{Pluralize64 .PushNotificationsCount}}. You may
<a href="/v1/web-push/unregister">click here</a> to reset your subscriptions. Devices that you actively use
(and had granted permission on before) may re-subscribe on your next visit.
</p>
{{end}}
<!-- Specific Push Notification Opt-out Form -->
<form method="POST" action="/settings">
{{InputCSRF}}
<input type="hidden" name="intent" value="push_notifications">
<div class="field">
<label class="label">Send a Web Push Notification when...</label>
<label class="checkbox">
<input type="checkbox"
name="notif_optout_push_messages"
value="true"
{{if ne (.CurrentUser.GetProfileField "notif_optout_push_messages") "true"}}checked{{end}}>
I receive a Direct Message on the main website
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox"
name="notif_optout_push_friends"
value="true"
{{if ne (.CurrentUser.GetProfileField "notif_optout_push_friends") "true"}}checked{{end}}>
I receive a new Friend Request
</label>
</div>
<div class="field">
<button type="submit" class="button is-primary">
<i class="fa fa-save mr-2"></i> Save Push Notification Settings
</button>
</div>
</form>
</div>
</div>
<div class="card mb-5">
<header class="card-header has-background-link"> <header class="card-header has-background-link">
<p class="card-header-title has-text-light"> <p class="card-header-title has-text-light">
<i class="fa fa-bell pr-2"></i> <i class="fa fa-bell pr-2"></i>
@ -1107,13 +1197,14 @@
<div class="field"> <div class="field">
<button type="submit" class="button is-primary"> <button type="submit" class="button is-primary">
<i class="fa fa-save mr-2"></i> Save Privacy Settings <i class="fa fa-save mr-2"></i> Save Settings
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div>
<!-- Account Settings --> <!-- Account Settings -->
<div id="account"> <div id="account">
@ -1143,8 +1234,8 @@
<p> <p>
Your Two-Factor is currently: Your Two-Factor is currently:
{{if .TwoFactorEnabled}} {{if .TwoFactorEnabled}}
<i class="fa fa-check mr-1 has-text-success-dark"></i> <i class="fa fa-check mr-1 has-text-success"></i>
<strong class="has-text-success-dark">Enabled</strong> <strong class="has-text-success">Enabled</strong>
{{else}} {{else}}
<i class="fa fa-xmark mr-1 has-text-danger"></i> <i class="fa fa-xmark mr-1 has-text-danger"></i>
<strong class="has-text-danger">Not Enabled</strong> <strong class="has-text-danger">Not Enabled</strong>
@ -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. // Location tab scripts.
window.addEventListener("DOMContentLoaded", (event) => { window.addEventListener("DOMContentLoaded", (event) => {
// Get useful controls from the tab. // Get useful controls from the tab.

View File

@ -387,6 +387,7 @@
<script type="text/javascript" src="/static/js/vue-3.2.45.js"></script> <script type="text/javascript" src="/static/js/vue-3.2.45.js"></script>
<script type="text/javascript" src="/static/js/htmx-1.9.12.min.js"></script> <script type="text/javascript" src="/static/js/htmx-1.9.12.min.js"></script>
<script type="text/javascript" src="/static/js/slim-forms.js?build={{.BuildHash}}"></script> <script type="text/javascript" src="/static/js/slim-forms.js?build={{.BuildHash}}"></script>
<script type="text/javascript" src="/static/js/web-push.js?build={{.BuildHash}}"></script>
{{template "scripts" .}} {{template "scripts" .}}
<!-- Likes modal --> <!-- Likes modal -->

View File

@ -70,10 +70,19 @@
<ul> <ul>
<li><a href="#chat-access">Who can access the chat rooms?</a></li> <li><a href="#chat-access">Who can access the chat rooms?</a></li>
<li><a href="#chat-support">What are the technical requirements to use the chat room?</a></li> <li><a href="#chat-support">What are the technical requirements to use the chat room?</a></li>
<li><a href="#webcam-support">I can't share or connect to other peoples' webcams</a></li> <li><a href="#webcam-support">I am experiencing a problem with <strong>webcam sharing</strong></a></li>
<li><a href="#chat-more">Where can I learn more about the chat room?</a></li> <li><a href="#chat-more">Where can I learn more about the chat room?</a></li>
</ul> </ul>
</li> </li>
<li>
<a href="#notification-faqs">Notification FAQs</a>
<ul>
<li><a href="#notifications">Does nonshy send me notifications?</a></li>
<li><a href="#web-push">About <strong>Web Push Notifications</strong></a></li>
<li><a href="#cancel-web-push">How do I turn off Web Push Notifications?</a></li>
<li><a href="#troubleshoot-web-push">Troubleshooting Web Push Notifications</a></li>
</ul>
</li>
<li> <li>
<a href="#shy-faqs">Shy Account FAQs</a> <a href="#shy-faqs">Shy Account FAQs</a>
<ul> <ul>
@ -908,56 +917,75 @@
<h3 id="chat-support">What are the technical requirements to use the chat room?</h3> <h3 id="chat-support">What are the technical requirements to use the chat room?</h3>
<p> <p>
The chat room seems to work the best on the following combination of devices and The chat room should generally work well on all major web browsers, operating systems
web browsers: 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.
</p> </p>
<ul>
<li>
<strong>Firefox</strong> and <strong>Chromium</strong>-based web browsers
on <strong>all desktop-like operating systems</strong> 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.
</li>
<li>
On <strong>Android</strong> devices, all <strong>Firefox</strong> and <strong>Chromium-based</strong>
browsers are generally working quite well.
</li>
</ul>
<p> <p>
The chat room and video sharing generally works well on the above devices. Below <strong>When opening many webcams:</strong> the number of cameras you can watch at a
are some that are known to have issues with the chat room at this time: 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.
</p> </p>
<ul> <p>
<li> For some examples: a Macbook Air M3 laptop from 2024 is able to comfortably open more
<strong>Safari on Mac OS X</strong> usually works for the text chat portions than 20 webcams at a time. However, a Dell XPS 13 laptop from 2018 (which had 16GB RAM,
(entering the room and chatting), but webcam sharing doesn't seem to work (either an nVIDIA graphics card, etc.) was seen to only be able to open 10 or 15 cameras before
broadcasting or viewing others' cameras). the laptop became very warm.
</li> </p>
<li>
<strong>iPhone and iPad</strong> (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.
</li>
</ul>
<h3 id="webcam-support">I can't share or connect to other peoples' webcams</h3> <h3 id="webcam-support">I am experiencing a problem with webcam sharing</h3>
<p> <p>
First, verify that you're using a <a href="#chat-support">known supported device</a> Please see the <strong><a href="/forum/thread/309">Webcam Troubleshooting</a></strong>
thread in the forums for some advice on things to try.
</p>
<p>
Verify that you're using a <a href="#chat-support">known supported device</a>
when accessing the chat room. Generally this means you're running a Firefox or 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 Chromium-based browser on a desktop computer, laptop, or Android device. The Apple
sharing does not work <strong>at all</strong> in Safari on Mac OS, and portable Safari browser should also work from a Mac, iPhone or iPad computer.
Apple devices such as iPad and iPhone do not work with the chat room at all. </p>
<p>
The most common type of error message people encounter on chat looks like:
<strong>NotAllowedError: Permission denied.</strong> This error message usually has
one of three causes:
</p>
<ol>
<li>
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.
</li>
<li>
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.
</li>
<li>
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.
</li>
</ol>
<p>
The linked Webcam Troubleshooting thread above has instructions for common web browsers
and operating systems on where to check for permission errors.
</p> </p>
<p> <p>
If you are on a supported device, check out the following information about how If you are on a supported device, check out the following information about how
webcam sharing works: webcam sharing works in general:
</p> </p>
<ul> <ul>
@ -1017,15 +1045,6 @@
</li> </li>
</ul> </ul>
<p>
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.
</p>
<h3 id="chat-more">Where can I learn more about the chat room?</h3> <h3 id="chat-more">Where can I learn more about the chat room?</h3>
<p> <p>
@ -1033,6 +1052,85 @@
a tour of the chat room interface and some additional information about how to use the chat room. a tour of the chat room interface and some additional information about how to use the chat room.
</p> </p>
<h1 id="notification-faqs">Notification FAQs</h1>
<h3 id="notifications">Does nonshy send me notifications?</h3>
<p>
Most {{PrettyTitle}} notifications are "on-site only" by default, meaning you need to log onto
the website to see them. We send <em>very</em> few e-mails from this website, ever: only for
your new account verification e-mail, certification photo approval, and when you forgot your password.
</p>
<p>
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
<a href="/settings#notifications">Notification Settings</a> page, to opt in or out of any of these.
</p>
<p>
We have optional <a href="#web-push">Web Push Notifications</a> that you may enable so you can
know when somebody has left you a message or a friend request on the website.
</p>
<h3 id="web-push">About Web Push Notifications</h3>
<p>
If you would like to enable timely notifications when you receive a new Message or Friend Request
on the website, you may enable <a href="/settings#notifications">Web Push Notifications</a> in your
settings.
</p>
<p>
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.
</p>
<p>
All your other notifications (likes, comments, etc.) are still "on-site only" so you will need to log in
and check your <a href="/me">Dashboard Page</a> to catch up on those.
</p>
<h3 id="cancel-web-push">How do I turn off Web Push Notifications?</h3>
<p>
The easiest way to turn these off is to revoke your Notifications permission given to the nonshy website.
</p>
<p>
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.
</p>
<img src="/static/img/site-settings.png" alt="A screenshot of the Google Chrome settings drop-down near the address bar." style="padding: 2px; border: 1px solid #666; background-color: #aaa">
<p>
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.
</p>
<h3 id="troubleshoot-web-push">Troubleshooting Web Push Notifications</h3>
<p>
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.
</p>
<p>
When this happens, the <a href="/settings#notifications">Notification Settings</a> page will say that you have "Denied"
notification permission.
</p>
<p>
In case you want to undo this: you will need to change or reset your Notification permission for this website. Please
see the <a href="#cancel-web-push">previous answer</a> for places to look. After you have reset your Notification permission,
refresh the <a href="/settings#notifications">Notification Settings</a> page and try enabling notifications again.
</p>
<h1 id="shy-faqs"><i class="fa fa-ghost"></i> Shy Account FAQs</h1> <h1 id="shy-faqs"><i class="fa fa-ghost"></i> Shy Account FAQs</h1>
<p> <p>

View File

@ -207,6 +207,13 @@
other column to read the conversation here. other column to read the conversation here.
</p> </p>
<!-- Push Notifications banner -->
<div class="notification is-success is-light" id="push-notification-tip" style="display: none">
<i class="fa fa-gift mr-1"></i>
<strong>New Feature:</strong> You can now enable <a href="/settings#notifications">Web Push Notifications</a>
so you can be notified that a new message was received on {{PrettyTitle}}, even if your web browser is not running.
</div>
<p> <p>
<i class="fa fa-info-circle has-text-success"></i> <i class="fa fa-info-circle has-text-success"></i>
<strong class="has-text-success">Pro Tip:</strong> <strong class="has-text-success">Pro Tip:</strong>
@ -335,3 +342,14 @@
</div> </div>
</div> </div>
{{end}} {{end}}
{{define "scripts"}}
<script type="text/javascript">
// Push Notification tip.
document.addEventListener("DOMContentLoaded", (e) => {
const permission = Notification.permission;
if (permission !== "granted") {
document.querySelector("#push-notification-tip").style.display = "";
}
});
</script>
{{end}}