From: mar77i Date: Tue, 15 Oct 2024 01:16:35 +0000 (+0200) Subject: more frontend, better websocket server. X-Git-Url: https://git.mar77i.info/?a=commitdiff_plain;h=b90ccea50c6e7ed184e265a4134f30566ac8d5cf;p=chat more frontend, better websocket server. --- diff --git a/.gitignore b/.gitignore index a2e0220..7285869 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ +chat/settings_local.py +.coverage .idea/ __pycache__/ -chat/settings_local.py staticfiles venv/ diff --git a/chat/migrations/0001_initial.py b/chat/migrations/0001_initial.py index e1ddeea..73207ba 100644 --- a/chat/migrations/0001_initial.py +++ b/chat/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.1 on 2024-09-30 15:56 +# Generated by Django 5.1.1 on 2024-10-04 21:43 import django.contrib.auth.models import django.contrib.auth.validators @@ -82,7 +82,7 @@ class Migration(migrations.Migration): ), pgtrigger.migrations.AddTrigger( model_name='user', - trigger=pgtrigger.compiler.Trigger(name='pg_notify_user', sql=pgtrigger.compiler.UpsertTriggerSql(func='\n IF TG_OP = \'DELETE\' THEN\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","old":{"id":\' || OLD."id" || \'}}\'\n );\n ELSE\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","new":{"id":\' || NEW."id" || \'}}\'\n );\n END IF;\n RETURN NULL;\n ', hash='2f48ed2d25af6836e62e7506ebb833638d21f225', operation='DELETE OR INSERT OR UPDATE', pgid='pgtrigger_pg_notify_user_152e9', table='chat_user', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='pg_notify_user', sql=pgtrigger.compiler.UpsertTriggerSql(func='\n IF TG_OP = \'DELETE\' THEN\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || OLD."id" || \'}}\'\n );\n ELSE\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || NEW."id" || \'}}\'\n );\n END IF;\n RETURN NULL;\n ', hash='7f6e8eba01193825febb722ab5558a49773b1c1a', operation='DELETE OR INSERT OR UPDATE', pgid='pgtrigger_pg_notify_user_152e9', table='chat_user', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='user', @@ -90,7 +90,7 @@ class Migration(migrations.Migration): ), pgtrigger.migrations.AddTrigger( model_name='channelmessage', - trigger=pgtrigger.compiler.Trigger(name='pg_notify_channelmessage', sql=pgtrigger.compiler.UpsertTriggerSql(func='\n IF TG_OP = \'DELETE\' THEN\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","old":{"id":\' || OLD."id" || \',"user_id":\' || OLD."user_id" || \',"channel_id":\' || OLD."channel_id" || \'}}\'\n );\n ELSE\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","new":{"id":\' || NEW."id" || \',"user_id":\' || NEW."user_id" || \',"channel_id":\' || NEW."channel_id" || \'}}\'\n );\n END IF;\n RETURN NULL;\n ', hash='8839d39c99595353dad9ba1de4805be117880a3b', operation='DELETE OR INSERT OR UPDATE', pgid='pgtrigger_pg_notify_channelmessage_d2b2e', table='chat_channelmessage', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='pg_notify_channelmessage', sql=pgtrigger.compiler.UpsertTriggerSql(func='\n IF TG_OP = \'DELETE\' THEN\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || OLD."id" || \',"user_id":\' || OLD."user_id" || \',"channel_id":\' || OLD."channel_id" || \'}}\'\n );\n ELSE\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || NEW."id" || \',"user_id":\' || NEW."user_id" || \',"channel_id":\' || NEW."channel_id" || \'}}\'\n );\n END IF;\n RETURN NULL;\n ', hash='b6e672dd9e454c168ef544260779943b1e40fe6d', operation='DELETE OR INSERT OR UPDATE', pgid='pgtrigger_pg_notify_channelmessage_d2b2e', table='chat_channelmessage', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='channelmessage', @@ -98,7 +98,7 @@ class Migration(migrations.Migration): ), pgtrigger.migrations.AddTrigger( model_name='channeluser', - trigger=pgtrigger.compiler.Trigger(name='pg_notify_channeluser', sql=pgtrigger.compiler.UpsertTriggerSql(func='\n IF TG_OP = \'DELETE\' THEN\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","old":{"id":\' || OLD."id" || \',"user_id":\' || OLD."user_id" || \',"channel_id":\' || OLD."channel_id" || \'}}\'\n );\n ELSE\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","new":{"id":\' || NEW."id" || \',"user_id":\' || NEW."user_id" || \',"channel_id":\' || NEW."channel_id" || \'}}\'\n );\n END IF;\n RETURN NULL;\n ', hash='e919ee13216581d3895b820bfef92e17397b7c1a', operation='DELETE OR INSERT OR UPDATE', pgid='pgtrigger_pg_notify_channeluser_f01cc', table='chat_channeluser', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='pg_notify_channeluser', sql=pgtrigger.compiler.UpsertTriggerSql(func='\n IF TG_OP = \'DELETE\' THEN\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || OLD."id" || \',"user_id":\' || OLD."user_id" || \',"channel_id":\' || OLD."channel_id" || \'}}\'\n );\n ELSE\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || NEW."id" || \',"user_id":\' || NEW."user_id" || \',"channel_id":\' || NEW."channel_id" || \'}}\'\n );\n END IF;\n RETURN NULL;\n ', hash='6d4422d5ba8d654603affe34f0cabebe53fe23b0', operation='DELETE OR INSERT OR UPDATE', pgid='pgtrigger_pg_notify_channeluser_f01cc', table='chat_channeluser', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='channeluser', @@ -106,7 +106,7 @@ class Migration(migrations.Migration): ), pgtrigger.migrations.AddTrigger( model_name='channel', - trigger=pgtrigger.compiler.Trigger(name='pg_notify_channel', sql=pgtrigger.compiler.UpsertTriggerSql(func='\n IF TG_OP = \'DELETE\' THEN\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","old":{"id":\' || OLD."id" || \'}}\'\n );\n ELSE\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","new":{"id":\' || NEW."id" || \'}}\'\n );\n END IF;\n RETURN NULL;\n ', hash='07e8b07cb0612a98869139e71cda37635e0d4fda', operation='DELETE OR INSERT OR UPDATE', pgid='pgtrigger_pg_notify_channel_fab0c', table='chat_channel', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='pg_notify_channel', sql=pgtrigger.compiler.UpsertTriggerSql(func='\n IF TG_OP = \'DELETE\' THEN\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || OLD."id" || \'}}\'\n );\n ELSE\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || NEW."id" || \'}}\'\n );\n END IF;\n RETURN NULL;\n ', hash='32baa6dfc09e87012fc270e79ff8a074b5c9ffcd', operation='DELETE OR INSERT OR UPDATE', pgid='pgtrigger_pg_notify_channel_fab0c', table='chat_channel', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='channel', @@ -114,7 +114,7 @@ class Migration(migrations.Migration): ), pgtrigger.migrations.AddTrigger( model_name='privatemessage', - trigger=pgtrigger.compiler.Trigger(name='pg_notify_privatemessage', sql=pgtrigger.compiler.UpsertTriggerSql(func='\n IF TG_OP = \'DELETE\' THEN\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","old":{"id":\' || OLD."id" || \',"sender_id":\' || OLD."sender_id" || \',"recipient_id":\' || OLD."recipient_id" || \'}}\'\n );\n ELSE\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","new":{"id":\' || NEW."id" || \',"sender_id":\' || NEW."sender_id" || \',"recipient_id":\' || NEW."recipient_id" || \'}}\'\n );\n END IF;\n RETURN NULL;\n ', hash='5b1db355e28bdcbe3440d36e8da1709c7cba8156', operation='DELETE OR INSERT OR UPDATE', pgid='pgtrigger_pg_notify_privatemessage_92534', table='chat_privatemessage', when='AFTER')), + trigger=pgtrigger.compiler.Trigger(name='pg_notify_privatemessage', sql=pgtrigger.compiler.UpsertTriggerSql(func='\n IF TG_OP = \'DELETE\' THEN\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || OLD."id" || \',"sender_id":\' || OLD."sender_id" || \',"recipient_id":\' || OLD."recipient_id" || \'}}\'\n );\n ELSE\n PERFORM pg_notify(\n \'pg_notify\',\n \'{"op":"\' || TG_OP || \'","table":"\' || TG_TABLE_NAME ||\n \'","obj":{"id":\' || NEW."id" || \',"sender_id":\' || NEW."sender_id" || \',"recipient_id":\' || NEW."recipient_id" || \'}}\'\n );\n END IF;\n RETURN NULL;\n ', hash='03ce1756ef8739ddbaac8c46c954bb7f29cfb352', operation='DELETE OR INSERT OR UPDATE', pgid='pgtrigger_pg_notify_privatemessage_92534', table='chat_privatemessage', when='AFTER')), ), pgtrigger.migrations.AddTrigger( model_name='privatemessage', diff --git a/chat/rest_views.py b/chat/rest_views.py index f891f77..2586802 100644 --- a/chat/rest_views.py +++ b/chat/rest_views.py @@ -183,11 +183,11 @@ class PrivateMessageRestView(ModelRestView): def get_queryset(self): queryset = super().get_queryset() - if "other" in self.request.GET: + recipient_id = self.request.GET.get("recipient_id") + if recipient_id is not None: queryset = queryset.filter( - Q(sender=self.request.user, recipient_id=self.request.GET["other"]) | Q( - sender_id=self.request.GET["other"], recipient=self.request.user - ) + Q(sender=self.request.user, recipient_id=recipient_id) + | Q(sender_id=recipient_id, recipient=self.request.user) ) else: queryset = queryset.filter( @@ -217,6 +217,6 @@ class ChannelMessageRestView(ModelRestView): queryset = super().get_queryset().filter( channel__users=self.request.user.pk ) - if "channel" in self.request.GET: - queryset = queryset.filter(channel_id=self.request.GET["channel"]) + if "channel_id" in self.request.GET: + queryset = queryset.filter(channel_id=self.request.GET["channel_id"]) return queryset.order_by("ts") diff --git a/chat/serializers.py b/chat/serializers.py index 1b1a1bc..744f279 100644 --- a/chat/serializers.py +++ b/chat/serializers.py @@ -31,8 +31,6 @@ class ModelSerializer: value = getattr(instance, field_name) if isinstance(field, DateTimeField): value = value.isoformat(" ") - elif isinstance(field, ForeignKey): - value = value.pk elif isinstance(field, (ManyToManyField, ManyToManyRel)): value = [v.pk for v in value.all()] return value @@ -51,8 +49,11 @@ class ModelSerializer: m2m[field_name] = value elif isinstance(field, ForeignKey): # disallow reassigning FKs on update - if instance.pk is None: - setattr(instance, f"{field_name}_id", value) + if instance.pk is not None: + raise ValueError(f"Not allowed to update: {field_name}") + if not isinstance(value, int): + raise ValueError(f"Please just add the _id field: {field_name}") + setattr(instance, field_name, value) else: setattr(instance, field_name, value) return instance, m2m @@ -90,19 +91,18 @@ class UserSerializer(ModelSerializer): class PrivateMessageSerializer(ModelSerializer): model = PrivateMessage - fields = ["id", "url", "sender", "recipient", "ts", "text"] + fields = ["id", "url", "sender_id", "recipient_id", "ts", "text"] def from_json(self, data, instance=None): if instance is None: data.update( { - "sender": self.request.user.pk, + "sender_id": self.request.user.pk, "ts": now().isoformat(" ") } ) else: - for key in ("sender", "recipient", "ts"): - data.pop(key, None) + data.pop("ts", None) return super().from_json(data, instance) @@ -127,7 +127,7 @@ class ChannelSerializer(ModelSerializer): class ChannelMessageSerializer(ModelSerializer): model = ChannelMessage - fields = ["id", "url", "user", "channel", "ts", "text"] + fields = ["id", "url", "user_id", "channel_id", "ts", "text"] def from_json(self, data, instance=None): instance, m2m = super().from_json(data, instance) diff --git a/chat/static/chat/chat.css b/chat/static/chat/chat.css index 271f979..6eda701 100644 --- a/chat/static/chat/chat.css +++ b/chat/static/chat/chat.css @@ -11,7 +11,7 @@ nav { overflow: auto; } -main, .messages, .input > textarea { +main, .messages, .messages_footer > textarea { width: 100%; } @@ -19,8 +19,8 @@ nav, main { flex-direction: column; } -nav, .input { - display:flex; +nav, .messages_footer { + display: flex; } .user, .users, .channels { @@ -33,10 +33,27 @@ nav, .input { nav > div { width: calc(100% - 1px); - border-bottom: 1px solid; border-right: 1px solid; } -.input > textarea { +.messages_footer > textarea { resize: none; } + +.messages_header, nav > div { + border-bottom: 1px solid; +} + +.messages_header > ul { + position: fixed; + width: calc(100% - 10em); + border: 1px solid; +} + +.messages { + overflow-y: auto; +} + +ul { + list-style-position: inside; +} diff --git a/chat/static/chat/chat.html b/chat/static/chat/chat.html new file mode 100644 index 0000000..c546f24 --- /dev/null +++ b/chat/static/chat/chat.html @@ -0,0 +1,42 @@ + + + + + + + Chat + + + + + + + +
+
+

+
    +
+
+
+
+ +
+ + diff --git a/chat/static/chat/chat.js b/chat/static/chat/chat.js index 5d91720..1dacf6c 100644 --- a/chat/static/chat/chat.js +++ b/chat/static/chat/chat.js @@ -1,293 +1,367 @@ (function () { - var current_channel = {}; - var users_per_id = null; - var channels_per_id = null; + var current_channel = null, private_manager = null, channel_manager = null; - function clear_children(element) { - while (element.firstChild) { - element.removeChild(element.firstChild); - } + var bottom_callback, no_bottom_callback; + bottom_callback = no_bottom_callback = function () { + bottom_callback = no_bottom_callback; + }; + + function xhr(method, url, callback, data) { + var cb = callback + ? function () { callback(JSON.parse(this.responseText)); } + : null; + return _.xhr( + method, + url, + cb, + function (msg) { add_msg(null, "xhr_error", msg); }, + function (msg) { add_msg(null, "xhr_abort", msg); }, + data, + ); } function get_data_id(element) { var attr = element.getAttribute("data-id"); - if (attr === null) - return null; - return Number(attr); + return attr !== null ? Number(attr) : null; } function get_current_user_id() { - return get_data_id(document.getElementsByClassName("user")[0].children[0]); + return get_data_id(_.dgEBCN0("user").children[0]); } function set_msg(p, sender, msg) { - clear_children(p); + _.clear_children(p); if (typeof sender === 'number') { - sender = users_per_id[sender].username; + sender = private_manager.items_per_id[sender].username; } - p.appendChild(document.createTextNode(sender)); - p.appendChild(document.createTextNode(": ")); - p.appendChild(document.createTextNode(msg)); - } - - function add_msg(msg_id, sender, msg) { - var p = document.createElement("P"); - p.setAttribute("data-id", msg_id); - set_msg(p, sender, msg); - document.getElementsByClassName("messages")[0].appendChild(p); + p.appendChild(_.dcTN(sender)); + p.appendChild(_.dcTN(": ")); + p.appendChild(_.dcTN(msg)); + return p; } - function find_existing_message(msg_id, callback) { - var i, messages = document.getElementsByClassName("messages")[0]; - for (i = 0; i < messages.children.length; i++) { - if (get_data_id(messages.children[i]) === msg_id) { - fetch_data(current_channel.url + msg_id + "/", callback); - return; - } + function make_tag_with_data_id(tag_name, data_id) { + var i, tag = document.createElement(tag_name); + if (data_id !== null) { + tag.setAttribute("data-id", data_id); + } + for (i = 2; i < arguments.length; i += 1) { + tag.appendChild(arguments[i]); } + return tag; } - function delete_existing_message(msg_id) { - var i, messages = document.getElementsByClassName("messages")[0]; - for (i = 0; i < messages.children.length; i++) { - if (get_data_id(messages.children[i]) === msg_id) { - messages.removeChild(messages.children[i]); - return; - } + function make_callback_a(callback) { + var a = document.createElement("a"), i; + a.setAttribute("href", "javascript:void(0)"); + a.addEventListener("click", callback); + for (i = 1; i < arguments.length; i += 1) { + a.appendChild(arguments[i]); } + return a; } - function ws_receive(msg) { - var data = JSON.parse(msg.data); - add_msg(null, "ws", msg.data); - if ( - current_channel.url === "/api/privatemessage/" - && data.table === "chat_privatemessage" - ) { - if ( - ( - data.new.sender_id === get_current_user_id() - && data.new.recipient_id === current_channel.data.recipient - ) || ( - data.new.sender_id === current_channel.data.recipient - && data.new.recipient_id === get_current_user_id() - ) - ) { - if (data.op === "INSERT") { - fetch_data( - current_channel.url + data.new.id + "/", add_privatemessage - ); - } else if (data.op === "UPDATE") { - find_existing_message(data.new.id, update_privatemessage); - } else if (data.op === "DELETE") { - delete_existing_message(data.old.id); - } else if (data.op === "TRUNCATE") { - clear_children(document.getElementsByClassName("messages")[0]); - } - } - } else if ( - current_channel.url === "/api/channelmessage/" - && data.table === "chat_channelmessage" - ) { - if (data.new.channel_id === current_channel.data.channel) { - if (data.op === "INSERT") { - fetch_data( - current_channel.url + data.new.id + "/", add_channelmessage - ); - } else if (data.op === "UPDATE") { - find_existing_message(data.new.id, update_channelmessage); - } else if (data.op === "DELETE") { - delete_existing_message(data.old.id); - } else if (data.op === "TRUNCATE") { - clear_children(document.getElementsByClassName("messages")[0]); + function find_existing_msg(msg_id, callback) { + var i, messages = _.dgEBCN0("messages"); + return _.foreach_arr( + messages.children, + function (item) { + if (get_data_id(item) === msg_id) { + callback(item); + return true; } } - } else if (data.table === "chat_user") { - reload_users(); - } else if (data.table === "chat_channel") { - reload_channels(); - } - } - - function reload_users() { - clear_children(document.getElementsByClassName("users")[0].children[0]); - fetch_data("/api/user/", setup_user_callback); + ); } - function reload_channels() { - clear_children(document.getElementsByClassName("channels")[0].children[0]); - fetch_data("/api/channel/", setup_channel_callback); + function add_msg(id, user, text) { + _.dgEBCN0("messages").appendChild( + set_msg(make_tag_with_data_id("p", id), user, text) + ); } - function xhr_error(msg) { - console.log(msg); - add_msg(null, "xhr_error", msg); + function add_msg_callback(item) { + add_msg(item.id, item[current_channel.msg_user_field], item.text); } - function xhr_abort(msg) { - console.log(msg); - add_msg(null, "xhr_abort", msg); + function clear_messages_header() { + var messages_header = _.dgEBCN0("messages_header"); + _.clear_children(messages_header.children[0]); + _.clear_children(messages_header.children[1]); + _.clear_after(messages_header.children[1]); } - function fetch_data(url, callback) { - var xhr = new XMLHttpRequest(); - xhr.addEventListener("load", callback); - xhr.addEventListener("error", xhr_error); - xhr.open("get", url); - xhr.send(); + function clear_channel() { + _.clear_children(_.dgEBCN0("messages")); + clear_messages_header(); + _.dgEBCN0("messages_header").style.display = "none"; + _.dgEBCN0("messages_footer").children[0].disabled = true; } - function post_data(url, data, callback) { - var xhr = new XMLHttpRequest(); - if (callback !== null) { - xhr.addEventListener("load", callback); + function set_channel(field_value) { + clear_channel(); + if (this === window) { + current_channel = null; + _.dgEBCN0("messages").appendChild(_.dcTN("No channel selected!")); + return; } - xhr.addEventListener("error", xhr_error); - xhr.addEventListener("abort", xhr_abort); - xhr.open("post", url); - xhr.send(data); - } - - function add_privatemessage() { - var msg = JSON.parse(this.responseText); - add_msg(msg.id, msg.sender, msg.text); - } - - function update_privatemessage() { - var msg = JSON.parse(this.responseText); - var i, messages = document.getElementsByClassName("messages")[0]; - for (i = 0; i < messages.children.length; i++) { - if (get_data_id(messages.children[i]) === msg.id) { - set_msg(messages.children[i], msg.sender, msg.text); - return; + current_channel = this; + this.msg_data[this.msg_dest_field] = field_value; + _.dgEBCN0("messages_footer").children[0].disabled = false; + current_channel.setup_channel_header(); + xhr( + "get", + this.msg_url + "?" + this.msg_dest_field + "=" + 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; } - } + ); } - function add_privatemessages() { - var msgs = JSON.parse(this.responseText), i; - for (i = 0; i < msgs.result.length; i += 1) { - add_msg(msgs.result[i].id, msgs.result[i].sender, msgs.result[i].text); + function update_channel_header() { + if (current_channel !== this) { + return; + } + if (this.items_per_id[this.msg_data.channel_id]) { + current_channel.setup_channel_header(); + } else { + set_channel.call(window); } } - function set_privatemessage_channel(user_id) { - var messages = document.getElementsByClassName("messages")[0]; - current_channel = { - "url": "/api/privatemessage/", - "data": {"recipient": user_id}, - } - clear_children(messages); - fetch_data( - "/api/privatemessage/?other=" + user_id.toString(), add_privatemessages - ); - document.getElementsByClassName("input")[0].children[0].removeAttribute( - "disabled" + function items_reload_callback(item) { + var t = this; + this.items_per_id[item.id] = item; + _.dgEBCN0(this.items_classname).children[0].appendChild( + make_tag_with_data_id( + "li", + item.id, + make_callback_a( + function () { t.set_channel(item.id); }, + _.dcTN(this.item_to_str(item)), + ), + ), ); } - function setup_user_callback() { - var data = JSON.parse(this.responseText), ul, li, a, p; - ul = document.getElementsByClassName("users")[0].children[0]; - users_per_id = {}; - for (i = 0; i < data.result.length; i += 1) { - users_per_id[data.result[i].id] = data.result[i]; - if (data.result[i].is_authenticated) { - p = document.createElement("P"); - p.setAttribute("data-id", data.result[i].id); - p.appendChild(document.createTextNode(data.result[i].username)); - document.getElementsByClassName("user")[0].appendChild(p); - } - li = document.createElement("li"); - li.setAttribute("data-id", data.result[i].id); - a = document.createElement("a"); - a.setAttribute("href", "#"); - a.appendChild( - document.createTextNode( - data.result[i].username + "\xa0(" + data.result[i].email + ")" - ) + function PrivateMessageManager() { + var t = this; + this.msg_url = "/api/privatemessage/"; + this.msg_table = "chat_privatemessage"; + this.msg_user_field = "sender_id"; + this.msg_dest_field = "recipient_id"; + this.msg_data = {}; + this.set_channel = set_channel.bind(this); + this.setup_channel_header = function () { + var messages_header = _.dgEBCN0("messages_header"); + clear_messages_header(); + messages_header.children[0].appendChild( + _.dcTN(this.item_to_str(this.items_per_id[this.msg_data.recipient_id])) ); - a.addEventListener( - "click", - (function (user_id) { - return function () { set_privatemessage_channel(user_id); }; - }(data.result[i].id)) + messages_header.style.display = "block"; + }; + this.items_url = "/api/user/"; + this.items_classname = "users"; + this.items_per_id = {}; + this.items_reload = function () { + console.log("a"); + _.clear_children(_.dgEBCN0(this.items_classname).children[0]); + _.clear_attributes(this.items_per_id); + console.log("b"); + xhr( + "get", + this.items_url, + function (data) { + console.log("items_reload callback", data); + _.foreach_arr( + data.result, + function (item) { + items_reload_callback.call(t, item); + if (!item.is_authenticated) { + return; + } + _.clear_children(_.dgEBCN0("user")); + _.dgEBCN0("user").appendChild( + make_tag_with_data_id( + "p", item.id, _.dcTN(item.username) + ), + ); + } + ); + update_channel_header.call(t); + }, ); - li.appendChild(a); - ul.appendChild(li); + console.log("c"); } + this.item_to_str = function (item) { + return item.username + "\xa0(" + item.email + ")"; + }; + this.match_channel = function (data) { + var user_id, recipient_id; + if (data.table !== this.msg_table) { + return false; + } + user_id = get_current_user_id(); + other_id = current_channel.msg_data.recipient_id; + return ( + data.obj.sender_id === user_id && data.obj.recipient_id === other_id + ) || ( + data.obj.sender_id === other_id && data.obj.recipient_id === user_id + ); + }; + this.items_reload(); } - function add_channelmessage() { - var msg = JSON.parse(this.responseText); - add_msg(msg.id, msg.user, msg.text); + function ChannelManager() { + var t = this; + this.msg_url = "/api/channelmessage/"; + this.msg_table = "chat_channelmessage"; + this.msg_user_field = "user_id"; + this.msg_dest_field = "channel_id"; + this.msg_data = {}; + this.set_channel = set_channel.bind(this); + this.setup_channel_header = function () { + var messages_header = _.dgEBCN0("messages_header"); + var btn = document.createElement("button"); + clear_messages_header(); + messages_header.children[0].appendChild( + _.dcTN(this.item_to_str(this.items_per_id[this.msg_data.channel_id])) + ); + _.foreach_arr( + this.items_per_id[this.msg_data.channel_id].users, + function (user_id) { + var item = private_manager.items_per_id[user_id]; + messages_header.children[1].appendChild( + make_tag_with_data_id( + "li", + item.id, + make_callback_a( + function () { private_manager.set_channel(item.id); }, + _.dcTN(private_manager.item_to_str(item)), + ), + ), + ); + }, + ); + btn.appendChild(_.dcTN("Users")); + messages_header.addEventListener("click", function () { + messages_header.children[1].style.display = "none"; + }); + btn.addEventListener("click", function (event) { + event = event || window.event; + if (messages_header.children[1].style.display === "none") { + messages_header.children[1].style.display = "block"; + messages_header.children[1].style.top = (btn.offsetTop + btn.offsetHeight).toString() + "px"; + } else { + messages_header.children[1].style.display = "none"; + } + event.preventDefault(); + event.stopPropagation(); + }); + 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"; + this.items_per_id = {}; + this.items_reload = function () { + _.clear_children(_.dgEBCN0(this.items_classname).children[0]); + _.clear_attributes(this.items_per_id); + xhr( + "get", + this.items_url, + function (data) { + _.foreach_arr(data.result, items_reload_callback.bind(t)); + update_channel_header.call(t); + }, + ); + } + this.item_to_str = function (item) { return item.name; }; + this.match_channel = function (data) { + var obj; + if (data.table !== this.msg_table) { + return false; + } + return data.obj.channel_id === current_channel.msg_data.channel_id; + }; + this.items_reload(); } - function update_channelmessage() { - var msg = JSON.parse(this.responseText); - var i, messages = document.getElementsByClassName("messages")[0]; - for (i = 0; i < messages.children.length; i++) { - if (get_data_id(messages.children[i]) === msg.id) { - set_msg(messages.children[i], msg.user, msg.text); - return; + 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 add_channelmessages() { - var msgs = JSON.parse(this.responseText), i; - for (i = 0; i < msgs.result.length; i += 1) { - add_msg(msgs.result[i].id, msgs.result[i].user, msgs.result[i].text); + function ws_receive_msg(data) { + var messages = _.dgEBCN0("messages"); + if (data.op === "INSERT") { + stick_to_bottom(messages); + xhr( + "get", + current_channel.msg_url + data.obj.id + "/", + function (item) { + add_msg_callback(item); + bottom_callback(); + } + ); + } else if (data.op === "UPDATE") { + 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(); + } + ); + }, + ); + } else if (data.op === "DELETE") { + find_existing_msg( + data.obj.id, function (tag) { tag.parentElement.removeChild(tag); } + ); + } else if (data.op === "TRUNCATE") { + _.clear_children(messages); } } - function set_channel(channel_id) { - var messages = document.getElementsByClassName("messages")[0]; - current_channel = { - "url": "/api/channelmessage/", - "data": {"channel": channel_id}, + function ws_receive(msg) { + var data = JSON.parse(msg.data); + // here we could add indication for the pm or channel that is 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(); } - clear_children(messages); - fetch_data( - "/api/channelmessage/?channel=" + channel_id.toString(), add_channelmessages - ); - document.getElementsByClassName("input")[0].children[0].removeAttribute( - "disabled" - ); - } - - function setup_channel_callback() { - var data = JSON.parse(this.responseText), ul, li, a; - ul = document.getElementsByClassName("channels")[0].children[0]; - channels_per_id = {}; - for (i = 0; i < data.result.length; i += 1) { - channels_per_id[data.result[i].id] = data.result[i]; - li = document.createElement("li"); - li.setAttribute("data-id", data.result[i].id); - a = document.createElement("a"); - a.setAttribute("href", "#"); - a.appendChild(document.createTextNode(data.result[i].name)); - a.addEventListener( - "click", - (function (channel_id) { - return function () { set_channel(channel_id); }; - }(data.result[i].id)) - ); - li.appendChild(a); - ul.appendChild(li); + if (current_channel !== null && current_channel.match_channel(data)) { + ws_receive_msg(data); } } - function send() { - var ta = document.getElementsByClassName("input")[0].children[0], data, name; + function send_msg() { + var ta = _.dgEBCN0("messages_footer").children[0], data, name; data = {"text": ta.value}; - for (name in current_channel.data) { - if (current_channel.data.hasOwnProperty(name)) { - data[name] = current_channel.data[name]; - } - } - if (current_channel.url) { - post_data(current_channel.url, JSON.stringify(data), null); + _.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 = ""; } } @@ -298,7 +372,7 @@ keycode = event.code || event.key; if (keycode === "Enter" || keycode === "NumpadEnter") { // todo: make it possible to add newlines using ctrl+enter - send(); + send_msg(); event.preventDefault(); event.stopPropagation(); } @@ -307,23 +381,21 @@ window.addEventListener( "load", function (event) { - var ws, schema, input; + var ws, schema, messages_footer; + messages_footer = _.dgEBCN0("messages_footer"); + set_channel.call(window); event = event || window.event; - input = document.getElementsByClassName("input")[0]; - if (!current_channel.url) { - input.children[0].setAttribute("disabled", ""); - } if (event.target.readyState !== "complete") { return; } + private_manager = new PrivateMessageManager(); + channel_manager = new ChannelManager(); schema = {"http:": "ws:", "https:": "wss:"}[window.location.protocol]; ws = new WebSocket(schema + "//" + window.location.host + "/"); ws.addEventListener("message", ws_receive); - input.children[0].addEventListener("keydown", input_onkeydown); - input.children[0].value = ""; - input.children[1].addEventListener("click", send); - reload_users(); - reload_channels(); + messages_footer.children[0].addEventListener("keydown", input_onkeydown); + messages_footer.children[0].value = ""; + messages_footer.children[1].addEventListener("click", send_msg); } ); }()); diff --git a/chat/static/chat/underscore.js b/chat/static/chat/underscore.js new file mode 100644 index 0000000..f97bc82 --- /dev/null +++ b/chat/static/chat/underscore.js @@ -0,0 +1,70 @@ +(function () { + function foreach_arr(arr, callback) { + var i; + for (i = arguments.length === 3 ? arguments[2] : 0; i < arr.length; i += 1) { + if (callback(arr[i]) === false) { + return true; + } + } + return false; + } + + function foreach_obj(obj, callback) { + var name; + for (name in obj) { + if (obj.hasOwnProperty(name) && callback(name, obj[name]) === true) { + return true; + } + } + return false; + } + + function clear_children(element) { + while (element.firstChild) { + element.removeChild(element.firstChild); + } + } + + function clear_after(element) { + while (element.nextSibling) { + element.parentElement.removeChild(element.nextSibling); + } + } + + function clear_attributes(obj) { + foreach_obj(obj, function (name) { delete obj[name]; }) + } + + function xhr(method, url, callback, error_callback, abort_callback, data) { + var xhr = new XMLHttpRequest(); + if (!error_callback) { + error_callback = function (msg) { console.log("xhr_error", msg) }; + } + if (!abort_callback) { + abort_callback = function (msg) { console.log("xhr_abort", msg) }; + } + xhr.addEventListener("error", error_callback); + xhr.addEventListener("abort", abort_callback); + if (callback) { + xhr.addEventListener("load", callback); + } + xhr.open(method, url); + if (method.toLowerCase() === "put" || method.toLowerCase() === "post") { + xhr.send(data); + } else { + xhr.send(); + } + return xhr; + } + + window._ = { + "dcTN": function (t) { return document.createTextNode(t); }, + "dgEBCN0": function (cn) { return document.getElementsByClassName(cn)[0]; }, + "foreach_arr": foreach_arr, + "foreach_obj": foreach_obj, + "clear_children": clear_children, + "clear_after": clear_after, + "clear_attributes": clear_attributes, + "xhr": xhr, + }; +}()); diff --git a/chat/tests.py b/chat/tests.py index f5ec2ab..bf73123 100644 --- a/chat/tests.py +++ b/chat/tests.py @@ -449,9 +449,6 @@ class ChatTest(ChatTestMixin, TestCase): response = self.client.get(user2_url) self.assertEqual(response.status_code, 302) self.assertEqual(response.headers["Location"], f"/login/?next={user2_url}") - - user2.is_ - cookies = self.login_user(user2, user2_password) response = self.client.get(user2_url, headers={"cookie": cookies}) data = json.loads(response.content) @@ -651,7 +648,7 @@ class ChatTest(ChatTestMixin, TestCase): json.dumps( { "id": None, - "recipient": user1.pk, + "recipient_id": user1.pk, "text": "hello pms world", } ), @@ -666,8 +663,8 @@ class ChatTest(ChatTestMixin, TestCase): "id": data["id"], "url": f"http://testserver{privatemessage_url}", "ts": data["ts"], - "sender": user2.pk, - "recipient": user1.pk, + "sender_id": user2.pk, + "recipient_id": user1.pk, "text": "hello pms world", } ) @@ -681,8 +678,8 @@ class ChatTest(ChatTestMixin, TestCase): data["url"], json.dumps( { - "sender": user3.pk, - "recipient": user3.pk, + #"sender_id": user3.pk, + #"recipient_id": user3.pk, "ts": "2022-02-22 22:22:22.363636+00:00", "text": "hello updated pms world", } @@ -697,8 +694,8 @@ class ChatTest(ChatTestMixin, TestCase): "id": data["id"], "url": f"http://testserver{privatemessage_url}", "ts": data["ts"], - "sender": user2.pk, - "recipient": user1.pk, + "sender_id": user2.pk, + "recipient_id": user1.pk, "text": "hello updated pms world", } ) @@ -710,8 +707,8 @@ class ChatTest(ChatTestMixin, TestCase): self.assertTrue( all( ( - (m["sender"] == user2.pk and m["recipient"] == user1.pk) - or (m["sender"] == user1.pk and m["recipient"] == user2.pk) + (m["sender_id"] == user2.pk and m["recipient_id"] == user1.pk) + or (m["sender_id"] == user1.pk and m["recipient_id"] == user2.pk) ) for m in data["result"] ) @@ -725,8 +722,8 @@ class ChatTest(ChatTestMixin, TestCase): "id": data["result"][0]["id"], "url": f"http://testserver{privatemessage_url}", "ts": data["result"][0]["ts"], - "sender": user2.pk, - "recipient": user1.pk, + "sender_id": user2.pk, + "recipient_id": user1.pk, "text": "hello updated pms world", }, ] @@ -741,7 +738,7 @@ class ChatTest(ChatTestMixin, TestCase): reverse("chat-channelmessage-list"), json.dumps( { - "channel": channel.pk, + "channel_id": channel.pk, "text": "hello world", } ), @@ -756,9 +753,9 @@ class ChatTest(ChatTestMixin, TestCase): "id": data["id"], "url": f"http://testserver{channelmessage_url}", "ts": data["ts"], - "channel": channel.pk, + "channel_id": channel.pk, "text": "hello world", - "user": user2.pk, + "user_id": user2.pk, } ) for x in range(10): diff --git a/chat/triggers.py b/chat/triggers.py index fe90c83..ed56437 100644 --- a/chat/triggers.py +++ b/chat/triggers.py @@ -17,13 +17,13 @@ def triggers_for_table(channel_name, model_name, fields): PERFORM pg_notify( '{channel_name}', '{{"op":"' || TG_OP || '","table":"' || TG_TABLE_NAME || - '","old":{{{fields_to_json('OLD', fields)}}}}}' + '","obj":{{{fields_to_json('OLD', fields)}}}}}' ); ELSE PERFORM pg_notify( '{channel_name}', '{{"op":"' || TG_OP || '","table":"' || TG_TABLE_NAME || - '","new":{{{fields_to_json('NEW', fields)}}}}}' + '","obj":{{{fields_to_json('NEW', fields)}}}}}' ); END IF; RETURN NULL; diff --git a/chat/views.py b/chat/views.py index e76a7a6..a31fb11 100644 --- a/chat/views.py +++ b/chat/views.py @@ -1,19 +1,21 @@ +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 from django.contrib.auth.models import update_last_login from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.views import ( LoginView as DjangoLoginView, 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 Http404 +from django.http import Http404, FileResponse 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 +from django.views.generic import TemplateView, View from django.views.generic.detail import SingleObjectTemplateResponseMixin from django.views.generic.edit import BaseCreateView, FormView, UpdateView @@ -21,8 +23,11 @@ from .forms import AuthenticationForm, RegisterForm, PasswordResetForm from .models import User -class ChatView(LoginRequiredMixin, TemplateView): - template_name = "chat/chat.html" +class ChatView(View): + 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") class LoginView(DjangoLoginView): diff --git a/chat/websocket.py b/chat/websocket.py index a658916..23b0c15 100644 --- a/chat/websocket.py +++ b/chat/websocket.py @@ -1,5 +1,8 @@ +import json import sys -from asyncio import CancelledError, ensure_future +from asyncio import ( + CancelledError, ensure_future, get_running_loop, run_coroutine_threadsafe +) from contextlib import contextmanager from functools import partial from io import BytesIO @@ -21,6 +24,7 @@ async def process_ws(receive, send): elif event['type'] == 'websocket.disconnect': return elif event['type'] == 'websocket.receive': + # ...maybe make it possible to request data through the ws? if event['text'] == 'ping': await send({ 'type': 'websocket.send', @@ -29,20 +33,89 @@ async def process_ws(receive, send): @contextmanager -def listen_notify_handler(conn, callback, loop): - loop.add_reader(conn.fileno(), partial(conn.execute, "SELECT 1")) - conn.add_notify_handler(callback) - conn.execute(f"LISTEN {settings.PG_NOTIFY_CHANNEL}") +def listen_notify_handler(connection, callback): + loop = get_running_loop() + loop.add_reader(connection.fileno(), partial(connection.execute, "SELECT 1")) + connection.add_notify_handler(callback) + connection.execute(f"LISTEN {settings.PG_NOTIFY_CHANNEL}") try: yield finally: - conn.execute(f"UNLISTEN {settings.PG_NOTIFY_CHANNEL}") - conn.remove_notify_handler(callback) - loop.remove_reader(conn.fileno()) + connection.execute(f"UNLISTEN {settings.PG_NOTIFY_CHANNEL}") + connection.remove_notify_handler(callback) + loop.remove_reader(connection.fileno()) -def process_triggers(send, notification): - ensure_future(send({"type": "websocket.send", "text": notification.payload})) +def filter_trigger_always(coro, data, user, user_channels): + ensure_future(coro) + + +def filter_trigger_channelmessage(coro, data, user, user_channels): + if data["obj"]["channel_id"] in user_channels: + ensure_future(coro) + else: + coro.close() + + +def get_user_channels(user): + return set(user.channels.all().values_list("pk", flat=True)) + + +def update_user_channels(coro, loop, user, user_channels): + new_channels = get_user_channels(user) + old_channels = user_channels - new_channels + if not old_channels and not new_channels - user_channels: + if coro is not None: + coro.close() + return + user_channels -= old_channels + user_channels.update(new_channels) + if coro is not None: + ensure_future(coro, loop=loop) + + +def filter_trigger_channeluser(coro, data, user, user_channels): + op = data["op"] + if op == "TRUNCATE": + if user_channels: + user_channels.clear() + ensure_future(coro) + else: + coro.close() + return + if data["obj"]["user_id"] != user.pk: + filter_trigger_channelmessage(coro, data, user, user_channels) + return + loop = get_running_loop() + run_coroutine_threadsafe( + sync_to_async(update_user_channels)(coro, loop, user, user_channels), loop + ) + + +def filter_trigger_privatemessage(coro, data, user, user_channels): + if user.pk in (data["obj"]["sender_id"], data["obj"]["recipient_id"]): + ensure_future(coro) + else: + coro.close() + + +filter_triggers = { + "chat_user": filter_trigger_always, + "chat_channel": filter_trigger_always, + "chat_channelmessage": filter_trigger_channelmessage, + "chat_channeluser": filter_trigger_channeluser, + "chat_privatemessage": filter_trigger_privatemessage, +} + + +def process_triggers(send, user, user_channels, notification): + data = json.loads(notification.payload) + filter_triggers[data["table"]]( + send({"type": "websocket.send", "text": notification.payload}), + data, + user, + user_channels, + ) async def handle_websocket(scope, receive, send): @@ -58,8 +131,10 @@ async def handle_websocket(scope, receive, send): return await sync_to_async(connection.connect)() + user_channels = await sync_to_async(get_user_channels)(request.user) with listen_notify_handler( - connection.connection, partial(process_triggers, send), receive.__self__.loop + connection.connection, + partial(process_triggers, send, request.user, user_channels), ): try: await process_ws(receive, send)