]> git.mar77i.info Git - chat/commitdiff
'load more' button and add some structure to the js
authormar77i <mar77i@protonmail.ch>
Tue, 5 Nov 2024 16:52:57 +0000 (17:52 +0100)
committermar77i <mar77i@protonmail.ch>
Tue, 5 Nov 2024 16:52:57 +0000 (17:52 +0100)
chat/static/chat/chat.css
chat/static/chat/chat.html [deleted file]
chat/static/chat/chat.js
chat/templates/chat/base.html
chat/templates/chat/chat.html
chat/urls.py
chat/views.py
chat/websocket.py
todo.txt

index b4dabaf88fe7d07dbd9b3cdcb748f90699719240..966a8d5b4d6a95b1131f48d19c247d71d5987753 100644 (file)
@@ -48,24 +48,23 @@ nav > div {
     border-bottom: 1px solid;
 }
 
-.messages_header ul {
+.messages_header ul {
     position: fixed;
     width: calc(100% - 10em);
     border: 1px solid;
 }
 
-.messages_header > button:first-of-type {
-    position: fixed;
-    top: .5em;
-    right: 1em;
+.messages_header h1, .messages_header button {
+    margin: .5em 1em;
 }
 
-.messages {
-    overflow-y: auto;
+.messages_header h1 {
+    display: inline-block;
+    min-width: 10%;
 }
 
-h1 {
-    margin: .5em 1em;
+.logout_button {
+    float: right;
 }
 
 ul {
@@ -76,6 +75,10 @@ li {
     padding: .25em;
 }
 
+.messages {
+    overflow-y: auto;
+}
+
 .messages > p {
     padding: .25em;
     border-bottom: 1px solid #444;
diff --git a/chat/static/chat/chat.html b/chat/static/chat/chat.html
deleted file mode 100644 (file)
index e068dc4..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-    <head>
-        <meta charset="UTF-8">
-        <meta name="viewport" content="width=device-width, initial-scale=1.0">
-        <meta http-equiv="X-UA-Compatible" content="ie=edge">
-        <title>Chat</title>
-        <link rel="stylesheet" href="static/chat/style.css">
-        <link rel="stylesheet" href="static/chat/chat.css">
-        <script src="static/chat/underscore.js"></script>
-        <script src="static/chat/chat.js"></script>
-    </head>
-    <body>
-        <nav>
-            <div class="user">
-                User
-            </div>
-            <div class="channels">
-                Channels
-                <ul>
-                </ul>
-            </div>
-            <div class="users">
-                Users
-                <ul>
-                </ul>
-            </div>
-        </nav>
-        <main>
-            <div class="messages_header">
-                <h1></h1>
-                <ul>
-                </ul>
-                <button>Log Out</button>
-            </div>
-            <div class="messages">
-            </div>
-            <div class="messages_footer">
-                <textarea></textarea>
-                <button>Send</button>
-            </div>
-        </main>
-    </body>
-</html>
index d7f93ac7f1ad40cd3c78c3a83f776cc9629ae33e..a7a7a55bca6e0bc75c18f7c3b7810d2239a65aa3 100644 (file)
@@ -2,14 +2,7 @@
     var current_channel = null;
     var private_manager = null;
     var channel_manager = null;
-    var bottom_callback;
-    var ws_receive_msg;
-    var ws_schemas = {"http:": "ws:", "https:": "wss:"};
-
-    function no_bottom_callback() {
-        bottom_callback = no_bottom_callback;
-    }
-    bottom_callback = no_bottom_callback;
+    var messages_manager = null;
 
     function maybe_redirect(data) {
         if (data.hasOwnProperty("Location")) {
             url,
             {
                 "abort": function (msg) {
-                    add_msg(null, "xhr_abort", msg);
+                    messages_manager.add_msg(null, "xhr_abort", msg, null);
                 },
                 "error": function (msg) {
-                    add_msg(null, "xhr_error", msg);
+                    messages_manager.add_msg(null, "xhr_error", msg, null);
                 },
                 "load": function () {
                     var response_data;
         return get_data_id(_.dgEBCN0("user").children[0]);
     }
 
-    function set_msg(p, sender, msg) {
-        _.clear_children(p);
-        if (typeof sender === "number") {
-            sender = private_manager.items_per_id[sender].username;
-        }
-        p.appendChild(_.dcTN(sender));
-        p.appendChild(_.dcTN(": "));
-        p.appendChild(_.dcTN(msg));
-        return p;
-    }
-
     function make_tag_with_data_id(tag_name, data_id) {
         var tag = document.createElement(tag_name);
-        if (data_id !== null) {
+        if (data_id) {
             tag.setAttribute("data-id", data_id);
         }
         return tag;
         return a;
     }
 
-    function find_existing_msg(msg_id, callback) {
-        var messages = _.dgEBCN0("messages");
-        return _.foreach_arr(
-            messages.children,
-            function (item) {
-                if (get_data_id(item) === msg_id) {
-                    callback(item);
-                    return true;
-                }
-            }
-        );
-    }
-
-    function add_msg(id, user, text) {
-        _.dgEBCN0("messages").appendChild(
-            set_msg(make_tag_with_data_id("p", id), user, text)
-        );
-    }
-
-    function add_msg_callback(item) {
-        add_msg(item.id, item[current_channel.msg_user_field], item.text);
-    }
-
     function clear_messages_header() {
         var messages_header = _.dgEBCN0("messages_header");
         _.clear_children(messages_header.children[0]);
     }
 
     function clear_channel() {
-        _.clear_children(_.dgEBCN0("messages"));
+        var messages_footer = _.dgEBCN0("messages_footer");
+        messages_manager.clear();
         clear_messages_header();
         _.dgEBCN0("messages_header").children[0].appendChild(_.dcTN("Chat"));
-        _.dgEBCN0("messages_footer").children[0].disabled = true;
+        messages_footer.children[0].value = "";
+        messages_footer.children[0].disabled = true;
     }
 
     function set_channel(field_value) {
         clear_channel();
         if (this === window) {
             current_channel = null;
-            _.dgEBCN0("messages").appendChild(_.dcTN("No channel selected!"));
+            messages_manager.add_msg(null, null, "No channel selected!", null);
             return;
         }
         current_channel = this;
                 + "=" + field_value.toString()
             ),
             function (msgs) {
-                var messages = _.dgEBCN0("messages");
-                // add a "load previous" button here
-                _.foreach_arr(msgs.result, add_msg_callback);
-                messages.scrollTop = (
-                    messages.scrollHeight - messages.clientHeight
-                );
+                messages_manager.load_msgs_callback(msgs);
+                messages_manager.scroll_to_bottom();
             }
         );
     }
     }
 
     function items_reload_callback(item) {
-        var t = this;
+        var outer = this;
         var li;
         var a;
         this.items_per_id[item.id] = item;
         li = make_tag_with_data_id("li", item.id);
         a = make_callback_a(
             function () {
-                t.set_channel(item.id);
+                outer.set_channel(item.id);
             }
         );
         a.appendChild(this.item_to_tn(item));
     }
 
     function PrivateMessageManager() {
-        var t = this;
+        var outer = this;
         this.msg_url = "/api/privatemessage/";
         this.msg_table = "chat_privatemessage";
         this.msg_user_field = "sender_id";
                         data.result,
                         function (item) {
                             var p;
-                            items_reload_callback.call(t, item);
+                            items_reload_callback.call(outer, item);
                             if (!item.is_authenticated) {
                                 return;
                             }
                             _.dgEBCN0("user").appendChild(p);
                         }
                     );
-                    update_channel_header.call(t);
+                    update_channel_header.call(outer);
                 }
             );
         };
     }
 
     function ChannelManager() {
-        var t = this;
+        var outer = this;
         this.msg_url = "/api/channelmessage/";
         this.msg_table = "chat_channelmessage";
         this.msg_user_field = "user_id";
             });
             messages_header.appendChild(btn);
             messages_header.children[1].style.display = "none";
-            // messages_header.style.display = "block";
         };
         this.items_url = "/api/channel/";
         this.items_classname = "channels";
                 "get",
                 this.items_url,
                 function (data) {
-                    _.foreach_arr(data.result, items_reload_callback.bind(t));
-                    update_channel_header.call(t);
+                    _.foreach_arr(
+                        data.result,
+                        items_reload_callback.bind(outer)
+                    );
+                    update_channel_header.call(outer);
                 }
             );
         };
         this.items_reload();
     }
 
-    function stick_to_bottom(element) {
-        if (element.clientHeight + element.scrollTop === element.scrollHeight) {
-            bottom_callback = function () {
-                element.scrollTop = element.scrollHeight - element.clientHeight;
-                no_bottom_callback();
-            };
-        } else {
-            no_bottom_callback();
-        }
+    function Message(id, user, text) {
+        this.id = id;
+        this.p = make_tag_with_data_id("p", id);
+        this.update = function (user, text) {
+            this.user = user || this.user;
+            this.text = text || this.text;
+            _.clear_children(this.p);
+            if (this.user) {
+                if (typeof this.user === "number") {
+                    user = private_manager.items_per_id[this.user].username;
+                } else {
+                    user = this.user;
+                }
+                this.p.appendChild(_.dcTN(user));
+                this.p.appendChild(_.dcTN(": "));
+            }
+            this.p.appendChild(_.dcTN(this.text));
+        };
+        this.update(user, text);
     }
 
-    ws_receive_msg = {
-        DELETE: function (data) {
-            find_existing_msg(
-                data.obj.id,
-                function (tag) {
-                    tag.parentElement.removeChild(tag);
-                }
-            );
-        },
-        INSERT: function (data) {
-            stick_to_bottom(_.dgEBCN0("messages"));
-            xhr(
-                "get",
-                current_channel.msg_url + data.obj.id + "/",
-                function (item) {
-                    add_msg_callback(item);
-                    bottom_callback();
-                }
-            );
-        },
-        TRUNCATE: function () {
-            _.clear_children(_.dgEBCN0("messages"));
-        },
-        UPDATE: function (data) {
-            var messages = _.dgEBCN0("messages");
-            stick_to_bottom(messages);
-            find_existing_msg(
-                data.obj.id,
-                function (tag) {
-                    stick_to_bottom(messages);
-                    xhr(
-                        "get",
-                        (
-                            current_channel.msg_url
-                            + get_data_id(tag).toString()
-                            + "/"
-                        ),
-                        function (msg) {
-                            set_msg(
-                                tag,
-                                msg[current_channel.msg_user_field],
-                                msg.text
-                            );
-                            bottom_callback();
-                        }
-                    );
-                }
-            );
+    function MessagesManager() {
+        var messages = _.dgEBCN0("messages");
+        this.msgs_by_id = {};
+        this.add_msg = function (id, user, text, next) {
+            var msg = new Message(id, user, text);
+            messages.insertBefore(msg.p, next || null);
+            if (id) {
+                this.msgs_by_id[id.toString(36)] = msg;
+            }
+        };
+        this.by_id = function (msg_id) {
+            return this.msgs_by_id[msg_id.toString(36)] || null;
+        };
+        this.clear = function () {
+            _.clear_attributes(this.msgs_by_id);
+            _.clear_children(messages);
+        };
+        this.remove = function (id) {
+            var msg = this.by_id(id);
+            if (msg) {
+                msg.p.parentElement.removeChild(msg.p);
+                delete this.msgs_by_id[msg.id.toString(36)];
+            }
+        };
+        this.previous_button = function (url) {
+            var p;
+            var button;
+            if (!url) {
+                return;
+            }
+            p = document.createElement("p");
+            button = document.createElement("button");
+            button.appendChild(_.dcTN("Load more"));
+            button.addEventListener("click", function () {
+                xhr(
+                    "get",
+                    url,
+                    function (msgs) {
+                        p.parentElement.removeChild(p);
+                        messages_manager.load_msgs_callback(
+                            msgs,
+                            messages.firstElementChild
+                        );
+                    }
+                );
+            });
+            messages.insertBefore(p, messages.firstElementChild);
+            p.appendChild(button);
+        };
+        this.load_msgs_callback = function (msgs, next) {
+            this.previous_button(msgs.previous);
+            _.foreach_arr(msgs.result, function (item) {
+                messages_manager.add_msg(
+                    item.id,
+                    item[current_channel.msg_user_field],
+                    item.text,
+                    next
+                );
+            });
+        };
+        function no_bottom_callback() {
+            messages_manager.bottom_callback = no_bottom_callback;
         }
-    };
+        this.bottom_callback = no_bottom_callback;
+        this.stick_to_bottom = function () {
+            if (
+                messages.clientHeight + messages.scrollTop
+                === messages.scrollHeight
+            ) {
+                messages_manager.bottom_callback = function () {
+                    messages_manager.scroll_to_bottom();
+                    no_bottom_callback();
+                };
+            } else {
+                no_bottom_callback();
+            }
+        };
+        this.scroll_to_bottom = function () {
+            messages.scrollTop = (
+                messages.scrollHeight - messages.clientHeight
+            );
+        };
+    }
 
-    function ws_receive(msg) {
-        var data = JSON.parse(msg.data);
-        maybe_redirect(data);
-        // here we could add indication for the pm or channel being modified
-        if (
-            data.table === "chat_channel" || data.table === "chat_channeluser"
-        ) {
-            channel_manager.items_reload();
-        } else if (data.table === "chat_user") {
-            private_manager.items_reload();
-        }
-        if (current_channel !== null && current_channel.match_channel(data)) {
-            ws_receive_msg[data.op](data);
+    function WebSocketManager() {
+        var ws_schemas = {"http:": "ws:", "https:": "wss:"};
+        var ws_receive_msg = {
+            DELETE: function (data) {
+                messages_manager.remove(data.obj.id);
+            },
+            INSERT: function (data) {
+                messages_manager.stick_to_bottom();
+                xhr(
+                    "get",
+                    current_channel.msg_url + data.obj.id + "/",
+                    function (item) {
+                        messages_manager.add_msg(
+                            item.id,
+                            item[current_channel.msg_user_field],
+                            item.text,
+                            null
+                        );
+                        messages_manager.bottom_callback();
+                    }
+                );
+            },
+            TRUNCATE: function () {
+                messages_manager.clear();
+            },
+            UPDATE: function (data) {
+                var msg = messages_manager.by_id(data.obj.id);
+                if (!msg) {
+                    return;
+                }
+                messages_manager.stick_to_bottom();
+                xhr(
+                    "get",
+                    (
+                        current_channel.msg_url
+                        + msg.id.toString()
+                        + "/"
+                    ),
+                    function (data) {
+                        msg.update(
+                            data[current_channel.msg_user_field],
+                            data.text
+                        );
+                        messages_manager.bottom_callback();
+                    }
+                );
+            }
+        };
+        var ws = new WebSocket(
+            ws_schemas[window.location.protocol]
+            + "//"
+            + window.location.host
+            + "/"
+        );
+        function ws_receive(msg) {
+            var data;
+            try {
+                data = JSON.parse(msg.data);
+            } catch {
+                data = msg.data;
+            }
+            maybe_redirect(data);
+            if (typeof data === "string") {
+                console.log(data);
+                return;
+            }
+            // here we could add indication for the pm or channel being modified
+            if (
+                data.table === "chat_channel"
+                || data.table === "chat_channeluser"
+            ) {
+                channel_manager.items_reload();
+            } else if (data.table === "chat_user") {
+                private_manager.items_reload();
+            }
+            if (current_channel && current_channel.match_channel(data)) {
+                ws_receive_msg[data.op](data);
+            }
         }
+        ws.addEventListener("message", ws_receive);
+        ws.addEventListener("close", function (event) {
+            event = event || window.event;
+            messages_manager.add_msg(null, "ws_close", "Closed", null);
+            // display reconnect button here
+        });
+        ws.addEventListener("error", function (event) {
+            event = event || window.event;
+            messages_manager.add_msg(null, "ws_error", event, null);
+            // display reconnect button here
+        });
     }
 
     function send_msg() {
         var ta = _.dgEBCN0("messages_footer").children[0];
-        var data = {"text": ta.value};
+        var data;
+        if (!current_channel || ta.value === "") {
+            return;
+        }
+        data = {"text": ta.value};
         _.foreach_obj(
             current_channel.msg_data,
             function (name, value) {
                 data[name] = value;
             }
         );
-        if (current_channel) {
-            xhr("post", current_channel.msg_url, null, JSON.stringify(data));
-            ta.value = "";
-        }
+        ta.disabled = true;
+        xhr(
+            "post",
+            current_channel.msg_url,
+            function () {
+                ta.value = "";
+                ta.disabled = false;
+            },
+            JSON.stringify(data)
+        );
     }
 
     function input_onkeydown(event) {
         }
     }
 
-    function logout() {
-        window.location.href = "/logout/";
-    }
-
     window.addEventListener(
         "load",
         function (event) {
             var messages_footer = _.dgEBCN0("messages_footer");
-            var ws;
+            messages_manager = new MessagesManager();
             set_channel.call(window);
             event = event || window.event;
             if (event.target.readyState !== "complete") {
             }
             private_manager = new PrivateMessageManager();
             channel_manager = new ChannelManager();
-            ws = new WebSocket(
-                ws_schemas[window.location.protocol]
-                + "//"
-                + window.location.host
-                + "/"
-            );
-            ws.addEventListener("message", ws_receive);
+            new WebSocketManager();
             messages_footer.children[0].addEventListener(
                 "keydown",
                 input_onkeydown
             );
             messages_footer.children[0].value = "";
             messages_footer.children[1].addEventListener("click", send_msg);
-            _.dgEBCN0("messages_header").children[2].addEventListener(
-                "click",
-                logout
-            );
         }
     );
 }());
index 94075d3b6f60b1982ac8786349671240e4bbe878..152655e5517bb6c7ea073fae2013591628fd2d9c 100644 (file)
@@ -1,10 +1,11 @@
-{% load static %}<!DOCTYPE html>
+{% load static %}
+<!DOCTYPE html>
 <html lang="{% block lang %}en{% endblock lang %}">
     <head>
         <meta charset="UTF-8">
         <meta name="viewport" content="width=device-width, initial-scale=1.0">
         <meta http-equiv="X-UA-Compatible" content="ie=edge">
-        <title>{% block title %}Title{% endblock title %}</title>
+        <title>Chat — {{ view.title }}</title>
         <link rel="stylesheet" href="{% static 'chat/style.css' %}">
         {% block head %}{% endblock head %}
     </head>
index 7c601faf34889624d43a6ae26f28d1ecc8eb08f0..d830515eed504d4edda34f7aae0b97053405d9d1 100644 (file)
@@ -2,12 +2,16 @@
 {% load static %}
 
 {% block head %}
-<link rel="stylesheet" href="{% static 'chat/chat.css' %}">
+    <link rel="stylesheet" href="">
+    <link rel="stylesheet" href="{% static 'chat/chat.css' %}">
+    <script src="{% static 'chat/underscore.js' %}"></script>
+    <script src="{% static 'chat/chat.js' %}"></script>
 {% endblock head %}
 
 {% block header %}
     <nav>
         <div class="user">
+            User
         </div>
         <div class="channels">
             Channels
 {% endblock header %}
 
 {% block main %}
+    <div class="messages_header">
+        <h1></h1>
+        <ul>
+        </ul>
+        <form action="/logout/" method="post" class="logout_button">
+            {% csrf_token %}
+            <button type="submit">Log Out</button>
+        </form>
+    </div>
     <div class="messages">
-        No channel selected!
     </div>
-    <div class="input">
+    <div class="messages_footer">
         <textarea></textarea>
         <button>Send</button>
     </div>
 {% endblock main %}
-
-{% block footer %}
-    <script src="{% static 'chat/chat.js' %}"></script>
-{% endblock footer %}
index 050ed2f8df3dd2b063e1f5cf140162bc6214f475..2e80f63e684337537949979630d1fbef08bf3f35 100644 (file)
@@ -18,6 +18,7 @@ Including another URLconf
 
 from django.conf import settings
 from django.contrib import admin
+from django.contrib.auth.views import LogoutView
 from django.contrib.staticfiles.views import serve as serve_staticfiles
 from django.urls import path, re_path
 from django.utils.safestring import mark_safe
@@ -32,7 +33,6 @@ from .views import (
     ChatView,
     ConfirmEmailView,
     LoginView,
-    LogoutView,
     PasswordResetTokenView,
     PasswordResetView,
     RegisterView,
@@ -88,7 +88,9 @@ urlpatterns = [
         ConfirmEmailView.as_view(),
         name="chat-confirm-email",
     ),
-    path("logout/", LogoutView.as_view(), name="chat-logout"),
+    path(
+        "logout/", LogoutView.as_view(next_page=settings.LOGIN_URL), name="chat-logout"
+    ),
     path("admin/", admin.site.urls),
     *UserRestView.get_urls("user"),
     *PrivateMessageRestView.get_urls("privatemessage"),
index f366f4f3e7df1530befbcdb570319a0658371560..4d28cbce61bf1306ae62eec8b55641f019bc071a 100644 (file)
@@ -1,4 +1,3 @@
-from asgiref.sync import sync_to_async
 from django.conf import settings
 from django.contrib.auth.forms import SetPasswordForm
 from django.contrib.auth.mixins import LoginRequiredMixin
@@ -7,17 +6,13 @@ from django.contrib.auth.tokens import default_token_generator
 from django.contrib.auth.views import (
     LoginView as DjangoLoginView,
 )
-from django.contrib.auth.views import (
-    LogoutView as DjangoLogoutView,
-)
-from django.contrib.staticfiles import finders
 from django.core.mail import mail_admins
 from django.core.signing import Signer
-from django.http import FileResponse, Http404, HttpResponseBase
+from django.http import Http404
 from django.urls import reverse, reverse_lazy
 from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
 from django.utils.safestring import mark_safe
-from django.views.generic import TemplateView, View
+from django.views.generic import TemplateView
 from django.views.generic.detail import SingleObjectTemplateResponseMixin
 from django.views.generic.edit import BaseCreateView, FormView, UpdateView
 
@@ -25,17 +20,12 @@ from .forms import AuthenticationForm, PasswordResetForm, RegisterForm
 from .models import User
 
 
-class ChatView(LoginRequiredMixin, View):
-    async def dispatch(self, request, *args, **kwargs):
-        result = sync_to_async(super().dispatch)(request, *args, **kwargs)
-        while not isinstance(result, HttpResponseBase):
-            result = await result
-        return result
+class ChatView(LoginRequiredMixin, TemplateView):
+    template_name = "chat/chat.html"
 
-    async def get(self, request, *args, **kwargs):
-        path = finders.find("chat/chat.html")
-        with open(path) as fh:
-            return FileResponse(fh.read(), filename=path, content_type="text/html")
+    @property
+    def title(self):
+        return str(self.request.user)
 
 
 class LoginView(DjangoLoginView):
@@ -44,25 +34,11 @@ class LoginView(DjangoLoginView):
     title = "Login"
 
 
-class LogoutView(DjangoLogoutView):
-    http_method_names = ["post", "options", "get"]
-    template_name = "chat/success.html"
-    title = "Logout"
-
-    def get(self, request, *args, **kwargs):
-        return self.post(request, *args, **kwargs)
-
-    def get_context_data(self, **kwargs):
-        context = super().get_context_data(**kwargs)
-        context["msg"] = context["title"]
-        return context
-
-
 class PasswordResetView(FormView):
     form_class = PasswordResetForm
     template_name = "chat/login.html"
-    title = "Reset Password"
     success_url = reverse_lazy("chat-reset-password-success")
+    title = "Reset Password"
 
     def form_valid(self, form):
         """If the form is valid, save the associated model."""
index 96af0d5fef318d7219fa5acafd4332de6570338d..0c5e4fe5fa45902f3f6e98f1cf222ca116e9db42 100644 (file)
@@ -160,3 +160,7 @@ async def handle_websocket(scope, receive, send):
             pass
         except Exception:
             print_exception(*sys.exc_info())
+            try:
+                await send({"type": "websocket.close"})
+            except Exception:
+                pass
index d4549ce1ebf3601aef368495c3b6db7d9ee0ae08..9be9c0a6a04bfee505f27b596534c42ed1e0010b 100644 (file)
--- a/todo.txt
+++ b/todo.txt
@@ -1,18 +1,22 @@
-[ ] "load more" button
-[ ] reconnect ws button; reload current channel, channel list and user list on reconnect
+[ ] display a reconnect button when ws is disconnected
+   - if reconnect is successful, reload all loaded messages, channel list and user list
 [ ] show message timestamps and message edit/delete menus
    - privileged users can edit / delete any message
-[ ] edit existing messages
+[ ] edit/delete existing messages
 [ ] new message(s) indicators in left panel
-[ ] update address bar per channel (fragment for now?)
+[ ] update address bar per channel #channel:<id> / #user:<id> for now?
 [ ] channel management: channel admin
 [ ] write email to user
 
-[ ] ws client infrastructure: repeated automatic reconnects, notify-throttle
-[ ] server-side throttling, but that needs to be accounted for on the client
+[ ] ws client infrastructure:
+   - notify-throttle
+   - automatic reconnects?
+[ ] server-side throttling, that then needs to be accounted for on the client
 
 [ ] tests for pg-trigger→notify websocket
    - somehow test each trigger statement individually, that is 4 statements * 5 models
 [ ] ws and chat tests?
-[ ] media uploads and view methods: download, image, video/audio player...
+[ ] media uploads and view methods: download, image, video/audio player
+[ ] games: connect 4, battleship...
+
 [ ] moderation: report messages to MANAGERS, including private ones