]> littlesong.place Git - littlesongplace.git/commitdiff
Add push notification settings
authorChris Fulljames <christianfulljames@gmail.com>
Sat, 24 May 2025 01:16:53 +0000 (21:16 -0400)
committerChris Fulljames <christianfulljames@gmail.com>
Sat, 24 May 2025 01:16:53 +0000 (21:16 -0400)
src/littlesongplace/__init__.py
src/littlesongplace/activity.py
src/littlesongplace/push_notifications.py
src/littlesongplace/sql/schema_update.sql
src/littlesongplace/static/nav.js
src/littlesongplace/static/service.js
src/littlesongplace/static/styles.css
src/littlesongplace/templates/activity.html
src/littlesongplace/templates/comment-thread.html
src/littlesongplace/templates/index.html

index 71727e18e2867d8c157fb60181f297022f4af95f..846b83ef75ffcbd5eae316c46e45d4eee6c87bd7 100644 (file)
@@ -1,7 +1,6 @@
 import base64
 import logging
 import os
-import random
 from datetime import datetime, timezone
 from logging.handlers import RotatingFileHandler
 from pathlib import Path
@@ -56,15 +55,6 @@ def index():
         for key, value in users.get_user_colors(user).items():
             user[key] = value
 
-    titles = [
-            ("Little Song Place", 2.0),
-            ("Lumpy Space Princess", 0.2),
-            ("Language Server Protocol", 0.1),
-            ("Liskov Substitution Principle", 0.1),
-    ]
-    titles, weights = zip(*titles)
-    title = random.choices(titles, weights)[0]
-
     rows = db.query(
             """
             SELECT * FROM jams
@@ -81,7 +71,6 @@ def index():
             "index.html",
             users=all_users,
             songs=page_songs,
-            page_title=title,
             ongoing_events=ongoing_events,
             upcoming_events=upcoming_events)
 
index e3df8d4ce9b2aa56f95609ccea3826e521cd587c..eb1ca5d3718f7dd8fb571c13bb89aab2c5655ac7 100644 (file)
@@ -2,7 +2,7 @@ from datetime import datetime, timezone
 
 from flask import Blueprint, redirect, render_template, session
 
-from . import comments, db, songs
+from . import comments, db, push_notifications, songs
 
 bp = Blueprint("activity", __name__)
 
@@ -91,7 +91,17 @@ def activity():
             [timestamp, session["userid"]])
     db.commit()
 
-    return render_template("activity.html", comments=notifications)
+    comment_push = False
+    song_push = False
+    if "subid" in session:
+        row = db.query(
+                "SELECT settings FROM users_push_subscriptions WHERE subid = ?",
+                [session["subid"]], one=True)
+        if row:
+            comment_push = (row["settings"] & push_notifications.SubscriptionSetting.COMMENTS) > 0
+            song_push = (row["settings"] & push_notifications.SubscriptionSetting.SONGS) > 0
+
+    return render_template("activity.html", comments=notifications, comment_push=comment_push, song_push=song_push)
 
 @bp.get("/new-activity")
 def new_activity():
index 1dd87c8ee7c0feb05f4382e665b8c5009e2e1038..dde8900d120eeb6e0824b61dd7b6445fba6719db 100644 (file)
@@ -1,13 +1,18 @@
 import json
 import threading
+import enum
 
 import pywebpush
-from flask import Blueprint, current_app, g, request
+from flask import Blueprint, current_app, g, request, session
 
 from . import auth, datadir, db
 
 bp = Blueprint("push-notifications", __name__, url_prefix="/push-notifications")
 
+class SubscriptionSetting(enum.IntEnum):
+    COMMENTS = 0x0001
+    SONGS = 0x0002
+
 @bp.post("/subscribe")
 @auth.requires_login
 def subscribe():
@@ -15,16 +20,49 @@ def subscribe():
         # Request must contain valid subscription JSON
         abort(400)
 
-    db.query(
+    row = db.query(
             """
-            INSERT INTO users_push_subscriptions (userid, subscription)
-            VALUES (?, ?)
+            INSERT INTO users_push_subscriptions (userid, subscription, settings)
+            VALUES (?, ?, ?)
+            RETURNING subid
             """,
-            [g.userid, json.dumps(request.json)])
+            [g.userid, json.dumps(request.json), 0], expect_one=True)
     db.commit()
 
     current_app.logger.info(f"{g.username} registered push subscription")
 
+    session["subid"] = row["subid"]
+
+    return {"status": "success"}
+
+@bp.post("/update-settings")
+@auth.requires_login
+def update_settings():
+    if not request.json:
+        # Request must contain valid subscription JSON
+        abort(400)
+
+    if "subid" not in session:
+        return {"status": "failed", "message": "no subid in current session"}
+
+    bitfield = 0
+    settings = request.json
+    if settings["comments"]:
+        bitfield |= SubscriptionSetting.COMMENTS
+    if settings["songs"]:
+        bitfield |= SubscriptionSetting.SONGS
+
+    db.query(
+            """
+            UPDATE users_push_subscriptions
+            SET settings = ?
+            WHERE subid = ? AND userid = ?
+            """,
+            [bitfield, session["subid"], g.userid])
+    db.commit()
+
+    current_app.logger.info(f"{g.username} updated push subscription settings: {bitfield:04x}")
+
     return {"status": "success"}
 
 def get_user_subscriptions(userid):
index 9b480f3c793cd5d87b69d2fe27b5e9d5760010b5..dde27a0de994de5a108800db8c4f1feedf6ef361 100644 (file)
@@ -3,6 +3,7 @@ CREATE TABLE users_push_subscriptions (
     subid INTEGER PRIMARY KEY,
     userid INTEGER NOT NULL,
     subscription TEXT NOT NULL,
+    settings INTEGER NOT NULL,
     FOREIGN KEY(userid) REFERENCES users(userid) ON DELETE CASCADE
 );
 
index dc89f3e3374954b0d5f44cbe9e612e6378f1ce1d..12736b477ab1b9bec1d4648b806c6f5abc1b2b9b 100644 (file)
@@ -39,18 +39,13 @@ document.addEventListener("DOMContentLoaded", async (e) => {
         let date = new Date(e.dataset.date);
         e.textContent = date.toLocaleString();
     });
-});
 
-async function requestNotificationPermission() {
-    const permission = await window.Notification.requestPermission();
-    if (permission === "granted") {
-        // Register service worker
+    // Register service worker
+    if ("serviceWorker" in navigator)
+    {
         navigator.serviceWorker.register("service.js");
     }
-    else {
-        console.log("Did not get permission to send notifications:", permission);
-    }
-}
+});
 
 function onLinkClick(event) {
     if (event.defaultPrevented) {
index 17bee8da553d4aa7668e48b58088b3c8636bd11f..30da09d9b49e70e9852c84c5eab99d4681f64e8f 100644 (file)
@@ -1,26 +1,3 @@
-const vapid_public_key = "BLsO37LostwqKch7SFr5Df0MexEoBOcujdMRY7wJurRPc_MGdz9rAkMrqs_dil4qSFxVbVyAA3FqLEPSL-WRNZs";
-
-self.addEventListener("activate", async () => {
-    try {
-        // Subscribe via browser's push service
-        const options = {userVisibleOnly: true, applicationServerKey: vapid_public_key};
-        const subscription = await self.registration.pushManager.subscribe(options);
-        console.log(JSON.stringify(subscription));
-
-        // Register subscription with LSP server
-        const response = await fetch(
-            "/push-notifications/subscribe", {
-                method: "post",
-                headers: {"Content-Type": "application/json"},
-                body: JSON.stringify(subscription)
-            }
-        );
-        console.log(response);
-    }
-    catch (err) {
-        console.log("Error while activating service:", err);
-    }
-});
 
 self.addEventListener("push", (event) => {
     if (event.data) {
index 0836ceb993a9cf83a609bcc9546c40d8ba26a955..b44642695fde24ae22fdda70c7b20bcbb093ee5b 100644 (file)
@@ -121,6 +121,22 @@ input[type=text], input[type=password], input[type=url], input[type=datetime-loc
     box-shadow: -2px -2px 0px 0px var(--black);
 }
 
+.boxy {
+    margin: 10px 0px;
+    padding: 10px;
+    box-shadow: 2px 2px 0px 0px;
+    border: 1px solid;
+    border-radius: var(--radius);
+}
+
+.boxy-lite {
+    margin: 10px 0px;
+    padding: 10px;
+    box-shadow: 1px 1px 0px 0px;
+    border: 1px solid;
+    border-radius: var(--radius);
+}
+
 select {
     border: none;
     background-color: var(--purple);
@@ -468,21 +484,6 @@ div.song-details {
     margin: 10px;
 }
 
-div.top-level-comment {
-    margin-top: 10px;
-    padding: 10px;
-    border: 2px solid;
-    border-radius: var(--radius);
-}
-
-div.reply-comment {
-    margin-top: 10px;
-    margin-bottom: 10px;
-    padding: 10px;
-    border: 2px solid;
-    border-radius: var(--radius);
-}
-
 div.comment-button-container {
     display: flex;
     gap: 10px;
@@ -617,15 +618,6 @@ div.player-info {
     font-size: 14px;
 }
 
-/* Activity */
-div.comment-notification {
-    margin: 10px;
-    padding: 10px;
-    box-shadow: 2px 2px 0px 0px;
-    border: 1px solid;
-    border-radius: var(--radius);
-}
-
 /* Platform-specific global overrides */
 @media screen and (max-width: 480px) {
     .desktop-only {
index 19f8f250f9591005a4f040d588489ff3a1688d09..d2a270a99dc37af1f77b4b1b0a1f26d155cac0f9 100644 (file)
 {% block title %}Activity{% endblock %}
 
 {% block body %}
-
-{% if comments %}
 <h1>activity</h1>
 
-    {% for comment in comments %}
-        <div class="comment-notification">
-            <a href="/users/{{ comment['comment_username'] }}" class="profile-link">{{ comment['comment_username'] }}</a>
-            {% if comment['replyto_content'] %}
-            replied to &quot;{{ comment['replyto_content'] }}&quot;
-            {% else %}
-            commented
-            {% endif %}
-            on
-            {% if 'songid' in comment %}
-            <a href="/song/{{ comment['content_userid'] }}/{{ comment['songid'] }}?action=view">{{ comment['title'] }}</a> -
-            {# Nothing to do for user profile #}
-            {% elif 'playlistid' in comment %}
-            <a href="/playlists/{{ comment['playlistid'] }}">{{ comment['name'] }}</a> -
-            {% elif 'eventid' in comment %}
-            <a href="/jams/{{ comment['jamid']}}/events/{{ comment['eventid'] }}">{{ comment['title'] }}</a> -
-            {% endif %}
-            <a href="/users/{{ comment['content_username'] }}" class="profile-link">{{ comment['content_username'] }}</a>
-            <div class="top-level-comment">
-                <a href="/users/{{ comment['comment_username'] }}" class="profile-link">{{ comment['comment_username'] }}</a>:
-                {{ comment['content'] }}
-
-                <div class="comment-button-container">
-                    {% if comment['replytoid'] %}
-                        <!-- Comment is already part of a thread; reply to the same thread -->
-                        <a href="/comment?threadid={{ comment['threadid'] }}&replytoid={{ comment['replytoid'] }}">Reply</a>
-                    {% else %}
-                        <!-- Comment is a top-level, reply to the comment -->
-                        <a href="/comment?threadid={{ comment['threadid'] }}&replytoid={{ comment['commentid'] }}">Reply</a>
-                    {% endif %}
-                </div>
+<button class="button" onclick="showSettings()" id="btn-show-settings">Settings</button>
+
+<div id="activity-settings" class="boxy" style="line-height: 1.5;" hidden>
+    <!--<span>Push notifications are currently disabled on this device.</span>-->
+    <!--<button onclick="enablePushNotifications()" class="button" style="float: right;">Enable</button>-->
+    Send me a push notification on this device when:
+    <br/>
+    <label><input type="checkbox" onclick="updateSettings()" id="comment-push" {% if comment_push %}checked{% endif %}>I get a new comment</label>
+    <br/>
+    <label><input type="checkbox" onclick="updateSettings()" id="song-push" {% if song_push %}checked{% endif %}>Anyone uploads a new song</label>
+</div>
+
+<br/>
+<br/>
+
+<script>
+function showSettings() {
+    document.getElementById("activity-settings").hidden = false;
+    document.getElementById("btn-show-settings").hidden = true;
+}
+
+async function updateSettings() {
+    const comment_push_enabled = document.getElementById("comment-push").checked;
+    const song_push_enabled = document.getElementById("song-push").checked;
+
+    // Enable/subscribe to notifications
+    if (comment_push_enabled || song_push_enabled)
+    {
+        await enablePushNotifications();
+    }
+
+    // Update notification settings
+    const response = await fetch(
+        "/push-notifications/update-settings", {
+            method: "post",
+            headers: {"Content-Type": "application/json"},
+            body: JSON.stringify({comments: comment_push_enabled, songs: song_push_enabled})
+        }
+    );
+}
+
+async function enablePushNotifications() {
+    if (!("serviceWorker" in navigator))
+    {
+        return;  // Browser does not support notifications
+    }
+
+    // Show notification permission request dialog
+    const permission = await window.Notification.requestPermission();
+    if (permission === "granted") {
+        // Subscribe to push notifications (if we don't already have an active subscription)
+        try {
+            const registration = await navigator.serviceWorker.getRegistration();
+            const existingSubscription = await registration.pushManager.getSubscription();
+            if (!existingSubscription)
+            {
+                // Subscribe via browser's push service
+                const vapid_public_key = "BLsO37LostwqKch7SFr5Df0MexEoBOcujdMRY7wJurRPc_MGdz9rAkMrqs_dil4qSFxVbVyAA3FqLEPSL-WRNZs";
+                const options = {userVisibleOnly: true, applicationServerKey: vapid_public_key};
+                const subscription = await registration.pushManager.subscribe(options);
+                console.log(JSON.stringify(subscription));
+
+                // Register subscription with LSP server
+                await fetch(
+                    "/push-notifications/subscribe", {
+                        method: "post",
+                        headers: {"Content-Type": "application/json"},
+                        body: JSON.stringify(subscription)
+                    }
+                );
+            }
+        }
+        catch (err) {
+            console.log("Error subscribing to push notifications:", err);
+        }
+    }
+    else {
+        console.log("Did not get permission to send notifications:", permission);
+    }
+}
+
+async function notificationsEnabled() {
+    const registration = await navigator.serviceWorker.getRegistration();
+    const permission = await registration.pushManager.permissionState();
+    if (permission == "granted") {
+        const existingSubscription = await registration.pushManager.getSubscription();
+        if (existingSubscription)
+        {
+            return true;
+        }
+    }
+    return false;
+}
+</script>
+
+<div id="activity-contents">
+    {% for comment in comments -%}
+    <div class="boxy">
+        <a href="/users/{{ comment['comment_username'] }}" class="profile-link">{{ comment['comment_username'] }}</a>
+        {% if comment['replyto_content'] %}
+        replied to &quot;{{ comment['replyto_content'] }}&quot;
+        {% else %}
+        commented
+        {% endif %}
+        on
+        {% if 'songid' in comment %}
+        <a href="/song/{{ comment['content_userid'] }}/{{ comment['songid'] }}?action=view">{{ comment['title'] }}</a> -
+        {# Nothing to do for user profile #}
+        {% elif 'playlistid' in comment %}
+        <a href="/playlists/{{ comment['playlistid'] }}">{{ comment['name'] }}</a> -
+        {% elif 'eventid' in comment %}
+        <a href="/jams/{{ comment['jamid']}}/events/{{ comment['eventid'] }}">{{ comment['title'] }}</a> -
+        {% endif %}
+        <a href="/users/{{ comment['content_username'] }}" class="profile-link">{{ comment['content_username'] }}</a>
+        <div class="boxy-lite">
+            <a href="/users/{{ comment['comment_username'] }}" class="profile-link">{{ comment['comment_username'] }}</a>:
+            {{ comment['content'] }}
+
+            <div class="comment-button-container">
+                {% if comment['replytoid'] %}
+                    <!-- Comment is already part of a thread; reply to the same thread -->
+                    <a href="/comment?threadid={{ comment['threadid'] }}&replytoid={{ comment['replytoid'] }}">Reply</a>
+                {% else %}
+                    <!-- Comment is a top-level, reply to the comment -->
+                    <a href="/comment?threadid={{ comment['threadid'] }}&replytoid={{ comment['commentid'] }}">Reply</a>
+                {% endif %}
             </div>
         </div>
+    </div>
     {% endfor %}
 
-{% else %}
-
-    Nothing to show here yet!
-
-{% endif %}
+    {% if not comments %} <p>Nothing to show here yet!</p> {% endif %}
+</div>
 
 {% endblock %}
index 6ac737dfddac0fcc72df3583adec7acd7befe590..1231e09b70108b3735a26427cf1e9b916265c2a6 100644 (file)
@@ -5,7 +5,7 @@
         {% endif %}
 
         {% for comment in comments %}
-        <div class="top-level-comment">
+        <div class="boxy-lite">
 
             <a href="/users/{{ comment['username'] }}" class="profile-link">{{ comment['username'] }}</a>:
             {{ (comment['content'].replace("\n", "<br>"))|safe }}
             {% endif %}
 
             {% for reply in comment['replies'] %}
-            <div class="reply-comment">
+            <div class="boxy-lite">
 
                 <a href="/users/{{ reply['username'] }}" class="profile-link">{{ reply['username'] }}</a>:
-                {{ reply['content'] }}
+                {{ (reply['content'].replace("\n", "<br>"))|safe }}
 
                 {% if current_userid == reply['userid'] or current_userid == thread_userid %}
                 <div class="comment-button-container">
index 92824ddf63c03233b2315af6a321c523f69be91d..bd9cc2a6789fbb7c5f6a871601ef2a9e4810fa3a 100644 (file)
@@ -1,11 +1,9 @@
 {% extends "base.html" %}
 
-{% block title %}{{ page_title }}{% endblock %}
+{% block title %}Little Song Place{% endblock %}
 
 {% block body %}
 
-<button onclick="requestNotificationPermission()" class="button">Enable Notifications</button>
-
 <h2>hello!</h2>
 <div style="display: flex; flex-direction: row; justify-content: center; gap: 10px; align-items: center;">
 <div>🎶</div>