From 8b37785d9b64b14d9d87061c225c9a4aa0728f91 Mon Sep 17 00:00:00 2001 From: odkhang Date: Sun, 21 Jul 2024 14:25:40 +0700 Subject: [PATCH] Enhance Chat Mentions (#156) * Enhance Chat Mentions * Add file package-lock.json * Clean trailing whitespaces * Rename migration file and update ChatEvent model --- server/tests/live/test_auth.py | 13 +- server/tests/live/test_chat.py | 48 ++-- server/tests/live/test_chat_direct.py | 235 ++++++++++++++++- server/tests/live/test_chat_mentions.py | 241 ++++++++++++++++++ .../core/migrations/0065_chat_mentions.py | 115 +++++++++ server/venueless/core/models/chat.py | 20 +- server/venueless/core/services/chat.py | 134 ++++++++-- server/venueless/core/services/user.py | 6 +- server/venueless/live/modules/auth.py | 3 +- server/venueless/live/modules/chat.py | 141 +++++++++- webapp/package-lock.json | 21 ++ webapp/package.json | 1 + webapp/src/components/Chat.vue | 55 +++- webapp/src/components/ChatContent.vue | 111 ++++++++ webapp/src/components/ChatInput.vue | 203 ++++++++++++--- webapp/src/components/ChatMessage.vue | 64 +---- webapp/src/components/ChatUserCard.vue | 47 ++-- webapp/src/components/RoomsSidebar.vue | 67 +++-- .../src/components/janus/JanusConference.vue | 4 +- .../src/components/janus/JanusVideoroom.vue | 4 +- webapp/src/lib/profile.js | 10 + webapp/src/lib/quill/emoji.js | 24 ++ webapp/src/lib/quill/mention.js | 90 +++++++ webapp/src/store/chat.js | 95 +++++-- webapp/src/store/index.js | 7 +- webapp/src/views/exhibitors/item.vue | 2 +- webapp/src/views/posters/item.vue | 2 +- 27 files changed, 1521 insertions(+), 242 deletions(-) create mode 100644 server/tests/live/test_chat_mentions.py create mode 100644 server/venueless/core/migrations/0065_chat_mentions.py create mode 100644 webapp/src/components/ChatContent.vue create mode 100644 webapp/src/lib/profile.js create mode 100644 webapp/src/lib/quill/emoji.js create mode 100644 webapp/src/lib/quill/mention.js diff --git a/server/tests/live/test_auth.py b/server/tests/live/test_auth.py index 5f5d51b0..f4f0d686 100644 --- a/server/tests/live/test_auth.py +++ b/server/tests/live/test_auth.py @@ -37,6 +37,7 @@ async def test_auth_with_client_id(world, announcement, inactive_announcement): "user.config", "chat.channels", "chat.read_pointers", + "chat.notification_counts", "exhibition", "announcements", } @@ -140,6 +141,7 @@ async def test_auth_with_jwt_token(index, world): "user.config", "chat.channels", "chat.read_pointers", + "chat.notification_counts", "exhibition", "announcements", } @@ -180,6 +182,7 @@ async def test_update_user(): "user.config", "chat.channels", "chat.read_pointers", + "chat.notification_counts", "exhibition", "announcements", } @@ -224,6 +227,7 @@ async def test_update_user(): "user.config", "chat.channels", "chat.read_pointers", + "chat.notification_counts", "exhibition", "announcements", } @@ -236,6 +240,7 @@ async def test_update_user(): @pytest.mark.asyncio @pytest.mark.django_db async def test_wrong_user_command(): + async with world_communicator() as c: await c.send_json_to(["authenticate", {"client_id": 4}]) response = await c.receive_json_from() @@ -271,6 +276,7 @@ async def test_auth_with_jwt_token_update_traits(world): "user.config", "chat.channels", "chat.read_pointers", + "chat.notification_counts", "exhibition", "announcements", } @@ -289,6 +295,7 @@ async def test_auth_with_jwt_token_update_traits(world): "user.config", "chat.channels", "chat.read_pointers", + "chat.notification_counts", "exhibition", "announcements", } @@ -321,6 +328,7 @@ async def test_auth_with_jwt_token_twice(world): "user.config", "chat.channels", "chat.read_pointers", + "chat.notification_counts", "exhibition", "announcements", } @@ -339,6 +347,7 @@ async def test_auth_with_jwt_token_twice(world): "user.config", "chat.channels", "chat.read_pointers", + "chat.notification_counts", "exhibition", "announcements", } @@ -362,6 +371,7 @@ async def test_fetch_user(): "user.config", "chat.channels", "chat.read_pointers", + "chat.notification_counts", "exhibition", "announcements", } @@ -457,6 +467,7 @@ async def test_auth_with_jwt_token_and_permission_traits(world): "user.config", "chat.channels", "chat.read_pointers", + "chat.notification_counts", "exhibition", "announcements", } @@ -1061,4 +1072,4 @@ async def test_anonymous_invite(client, world, stream_room, bbb_room): "room:question.ask", "room:poll.vote", "room:poll.read", - } + } \ No newline at end of file diff --git a/server/tests/live/test_chat.py b/server/tests/live/test_chat.py index d1613696..cbdc4d49 100644 --- a/server/tests/live/test_chat.py +++ b/server/tests/live/test_chat.py @@ -69,14 +69,14 @@ async def test_join_leave(chat_room): await c.send_json_to(["chat.join", 123, {"channel": str(chat_room.channel.id)}]) response = await c.receive_json_from() response[2]["next_event_id"] = -1 - response[2]["notification_pointer"] = -1 + response[2]["unread_pointer"] = -1 assert response == [ "success", 123, { "state": None, "next_event_id": -1, - "notification_pointer": -1, + "unread_pointer": -1, "members": [], }, ] @@ -150,7 +150,7 @@ async def test_join_volatile_based_on_room_config(volatile_chat_room, chat_room, "chat.channels", { "channels": [ - {"id": str(chat_room.channel.id), "notification_pointer": 0} + {"id": str(chat_room.channel.id), "unread_pointer": 0} ] }, ] @@ -243,7 +243,7 @@ async def test_subscribe_without_name(chat_room): { "state": None, "next_event_id": -1, - "notification_pointer": 0, + "unread_pointer": 0, "members": [], }, ] @@ -275,7 +275,7 @@ async def test_subscribe_join_leave(chat_room): ) response = await c.receive_json_from() response[2]["next_event_id"] = -1 - response[2]["notification_pointer"] = -1 + response[2]["unread_pointer"] = -1 assert response == [ "success", 123, @@ -283,13 +283,13 @@ async def test_subscribe_join_leave(chat_room): "state": None, "next_event_id": -1, "members": [], - "notification_pointer": -1, + "unread_pointer": -1, }, ] await c.send_json_to(["chat.join", 123, {"channel": str(chat_room.channel.id)}]) response = await c.receive_json_from() response[2]["next_event_id"] = -1 - response[2]["notification_pointer"] = -1 + response[2]["unread_pointer"] = -1 assert response == [ "success", 123, @@ -297,7 +297,7 @@ async def test_subscribe_join_leave(chat_room): "state": None, "next_event_id": -1, "members": [], - "notification_pointer": -1, + "unread_pointer": -1, }, ] response = await c.receive_json_from() @@ -340,7 +340,7 @@ async def test_bogus_command(chat_room): await c.send_json_to(["chat.join", 123, {"channel": str(chat_room.channel.id)}]) response = await c.receive_json_from() response[2]["next_event_id"] = -1 - response[2]["notification_pointer"] = -1 + response[2]["unread_pointer"] = -1 assert response == [ "success", 123, @@ -348,7 +348,7 @@ async def test_bogus_command(chat_room): "state": None, "next_event_id": -1, "members": [], - "notification_pointer": -1, + "unread_pointer": -1, }, ] await c.receive_json_from() # join notification @@ -555,7 +555,7 @@ async def test_fetch_messages_after_join(chat_room): ) response = await c1.receive_json_from() response[2]["next_event_id"] = -1 - response[2]["notification_pointer"] = -1 + response[2]["unread_pointer"] = -1 assert response == [ "success", 123, @@ -563,7 +563,7 @@ async def test_fetch_messages_after_join(chat_room): "state": None, "next_event_id": -1, "members": [], - "notification_pointer": -1, + "unread_pointer": -1, }, ] await c1.receive_json_from() # join notification c1 @@ -670,14 +670,14 @@ async def test_send_message_to_other_client(chat_room): ) response = await c1.receive_json_from() response[2]["next_event_id"] = -1 - response[2]["notification_pointer"] = -1 + response[2]["unread_pointer"] = -1 assert response == [ "success", 123, { "state": None, "next_event_id": -1, - "notification_pointer": -1, + "unread_pointer": -1, "members": [], }, ] @@ -765,14 +765,14 @@ async def test_no_messages_after_leave(chat_room): ) response = await c1.receive_json_from() response[2]["next_event_id"] = -1 - response[2]["notification_pointer"] = -1 + response[2]["unread_pointer"] = -1 assert response == [ "success", 123, { "state": None, "next_event_id": -1, - "notification_pointer": -1, + "unread_pointer": -1, "members": [], }, ] @@ -855,7 +855,7 @@ async def test_no_message_after_unsubscribe(chat_room): { "state": None, "next_event_id": -1, - "notification_pointer": 0, + "unread_pointer": 0, "members": [], }, ] @@ -916,7 +916,7 @@ async def test_no_message_after_unsubscribe(chat_room): ] response = await c2.receive_json_from() - assert response[0] == "chat.notification_pointers" + assert response[0] == "chat.unread_pointer" with pytest.raises(asyncio.TimeoutError): await c2.receive_json_from() @@ -939,7 +939,7 @@ async def test_disconnect_is_no_leave(chat_room): "state": None, "members": [], "next_event_id": 1, - "notification_pointer": 0, + "unread_pointer": 0, }, ] await c2.send_json_to( @@ -982,7 +982,7 @@ async def test_last_disconnect_is_leave_in_volatile_channel(world, volatile_chat "state": None, "members": [], "next_event_id": 1, - "notification_pointer": 0, + "unread_pointer": 0, }, ] @@ -1163,7 +1163,7 @@ async def test_unread_channels(world, chat_room): # c2 gets a notification pointer response = await c2.receive_json_from() # receives notification pointer - assert response[0] == "chat.notification_pointers" + assert response[0] == "chat.unread_pointer" assert channel_id in response[1] # c1 sends a message @@ -1214,7 +1214,7 @@ async def test_unread_channels(world, chat_room): # c2 gets a notification pointer response = await c2.receive_json_from() # receives notification pointer - assert response[0] == "chat.notification_pointers" + assert response[0] == "chat.unread_pointer" assert response[1] == {channel_id: event_id + 1} with pytest.raises(asyncio.TimeoutError): @@ -1280,7 +1280,7 @@ async def test_broadcast_read_channels(world, chat_room): assert c3.context["chat.channels"] == [ { "id": channel_id, - "notification_pointer": event_id, + "unread_pointer": event_id, } ] assert c3.context["chat.read_pointers"] == {channel_id: event_id} @@ -1323,4 +1323,4 @@ async def test_force_join_after_login(world, chat_room): # Some asyncio test weirdness, I don't get why r = await c2.receive_json_from() assert r[0] == "chat.channels" - assert channel_id in [c["id"] for c in r[1]["channels"]] + assert channel_id in [c["id"] for c in r[1]["channels"]] \ No newline at end of file diff --git a/server/tests/live/test_chat_direct.py b/server/tests/live/test_chat_direct.py index 5044f6ee..931d1dfb 100644 --- a/server/tests/live/test_chat_direct.py +++ b/server/tests/live/test_chat_direct.py @@ -12,18 +12,19 @@ @asynccontextmanager -async def world_communicator(client_id): +async def world_communicator(client_id=None): communicator = LoggingCommunicator(application, "/ws/world/sample/") await communicator.connect() - await communicator.send_json_to(["authenticate", {"client_id": client_id}]) - response = await communicator.receive_json_from() - assert response[0] == "authenticated", response - communicator.context = response[1] - assert "world.config" in response[1], response - await communicator.send_json_to( - ["user.update", 123, {"profile": {"display_name": client_id}}] - ) - await communicator.receive_json_from() + if client_id: + await communicator.send_json_to(["authenticate", {"client_id": client_id}]) + response = await communicator.receive_json_from() + assert response[0] == "authenticated", response + communicator.context = response[1] + assert "world.config" in response[1], response + await communicator.send_json_to( + ["user.update", 123, {"profile": {"display_name": client_id}}] + ) + await communicator.receive_json_from() try: yield communicator finally: @@ -85,7 +86,7 @@ async def test_start_direct_channel(world): response = await c1.receive_json_from() assert "success" == response[0] assert "id" in response[2] - assert "notification_pointer" in response[2] + assert "unread_pointer" in response[2] assert "state" in response[2] assert "a" in {a["profile"]["display_name"] for a in response[2]["members"]} assert "b" in {a["profile"]["display_name"] for a in response[2]["members"]} @@ -506,7 +507,8 @@ async def test_send_if_blocked_by_user(world): await c1.receive_json_from() # chat event await c2.receive_json_from() # channel list - await c2.receive_json_from() # new notification pointer + await c2.receive_json_from() # new unread pointer + await c2.receive_json_from() # new notificatoin counts await c2.send_json_to( [ @@ -702,9 +704,11 @@ async def test_hide_and_reappear(world): cl = await c2.receive_json_from() # channel list assert channel in [c["id"] for c in cl[1]["channels"]] await c2.receive_json_from() # notification pointer + await c2.receive_json_from() # unread pointer await c2b.receive_json_from() # channel list - await c2b.receive_json_from() # notification pointer + await c2b.receive_json_from() # notification counts + await c2b.receive_json_from() # unread pointer await c2.send_json_to( [ @@ -787,3 +791,208 @@ async def test_send_if_silenced(world): response = await c1.receive_json_from() assert "error" == response[0] assert "chat.denied" == response[2]["code"] + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_notification_contains_content_and_persists(world): + world.trait_grants["participant"] = [] + await database_sync_to_async(world.save)() + async with world_communicator(client_id="a") as c1, world_communicator( + client_id="b" + ) as c2: + channel = await _setup_dms(c1, c2) + + # First message + await c1.send_json_to( + [ + "chat.send", + 123, + { + "event_type": "channel.message", + "content": {"type": "text", "body": "Hello world"}, + "channel": channel, + }, + ] + ) + response = await c1.receive_json_from() + assert "success" == response[0] + + resp = await c1.receive_json_from() + assert "chat.event" == resp[0] + + resp = await c2.receive_json_from() + assert "chat.channels" == resp[0] + + resp = await c2.receive_json_from() + assert "chat.unread_pointers" == resp[0] + + resp = await c2.receive_json_from() + assert "chat.notification" == resp[0] + assert "a" == resp[1]["sender"]["profile"]["display_name"] + assert channel == resp[1]["event"]["channel"] + assert "Hello world" == resp[1]["event"]["content"]["body"] + + # Second message + await c1.send_json_to( + [ + "chat.send", + 123, + { + "event_type": "channel.message", + "content": {"type": "text", "body": "This is great"}, + "channel": channel, + }, + ] + ) + response = await c1.receive_json_from() + assert "success" == response[0] + + resp = await c1.receive_json_from() + assert "chat.event" == resp[0] + + resp = await c2.receive_json_from() + assert "chat.unread_pointers" == resp[0] + + resp = await c2.receive_json_from() + assert "chat.notification" == resp[0] + assert "a" == resp[1]["sender"]["profile"]["display_name"] + assert channel == resp[1]["event"]["channel"] + assert "This is great" == resp[1]["event"]["content"]["body"] + + async with world_communicator() as c2: + await c2.send_json_to(["authenticate", {"client_id": "b"}]) + response = await c2.receive_json_from() + assert response[0] == "authenticated" + assert response[1]["chat.notification_counts"] == {channel: 2} + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_notification_sync_read_state_across_clients(world): + world.trait_grants["participant"] = [] + await database_sync_to_async(world.save)() + async with world_communicator(client_id="a") as c1, world_communicator( + client_id="b" + ) as c2, world_communicator() as c2b: + channel = await _setup_dms(c1, c2) + + await c2b.send_json_to(["authenticate", {"client_id": "b"}]) + response = await c2b.receive_json_from() + assert response[0] == "authenticated" + + # First message + await c1.send_json_to( + [ + "chat.send", + 123, + { + "event_type": "channel.message", + "content": {"type": "text", "body": "Hello world"}, + "channel": channel, + }, + ] + ) + response = await c1.receive_json_from() + assert "success" == response[0] + + resp = await c1.receive_json_from() + assert "chat.event" == resp[0] + + resp = await c2.receive_json_from() + assert "chat.channels" == resp[0] + resp = await c2b.receive_json_from() + assert "chat.channels" == resp[0] + + resp = await c2.receive_json_from() + assert "chat.unread_pointers" == resp[0] + resp = await c2b.receive_json_from() + assert "chat.unread_pointers" == resp[0] + + resp = await c2.receive_json_from() + assert "chat.notification" == resp[0] + resp = await c2b.receive_json_from() + assert "chat.notification" == resp[0] + event_id1 = resp[1]["event"]["event_id"] + + # Second message + await c1.send_json_to( + [ + "chat.send", + 123, + { + "event_type": "channel.message", + "content": {"type": "text", "body": "This is great"}, + "channel": channel, + }, + ] + ) + response = await c1.receive_json_from() + assert "success" == response[0] + + resp = await c1.receive_json_from() + assert "chat.event" == resp[0] + + resp = await c2.receive_json_from() + assert "chat.unread_pointers" == resp[0] + resp = await c2b.receive_json_from() + assert "chat.unread_pointers" == resp[0] + + resp = await c2.receive_json_from() + assert "chat.notification" == resp[0] + resp = await c2b.receive_json_from() + assert "chat.notification" == resp[0] + event_id2 = resp[1]["event"]["event_id"] + + await c2.send_json_to( + [ + "chat.mark_read", + 123, + { + "channel": channel, + "id": event_id1, + }, + ] + ) + await c2.receive_json_from() # success + + response = await c2.receive_json_from() # receives notification counter + assert response[0] == "chat.notification_counts" + assert response[1] == {channel: 1} + + response = await c2b.receive_json_from() # receives unread pointer + assert response[0] == "chat.read_pointers" + assert response[1] == {channel: event_id1} + response = await c2b.receive_json_from() # receives notification counter + assert response[0] == "chat.notification_counts" + assert response[1] == {channel: 1} + + await c2b.send_json_to( + [ + "chat.mark_read", + 123, + { + "channel": channel, + "id": event_id2, + }, + ] + ) + await c2b.receive_json_from() # success + + response = await c2b.receive_json_from() # receives notification counter + assert response[0] == "chat.notification_counts" + assert response[1] == {} + + response = await c2.receive_json_from() # receives unread pointer + assert response[0] == "chat.read_pointers" + assert response[1] == {channel: event_id2} + response = await c2.receive_json_from() # receives notification counter + assert response[0] == "chat.notification_counts" + assert response[1] == {} + + async with world_communicator() as c2: + await c2.send_json_to(["authenticate", {"client_id": "b"}]) + response = await c2.receive_json_from() + assert response[0] == "authenticated" + assert response[1]["chat.notification_counts"] == {} + \ No newline at end of file diff --git a/server/tests/live/test_chat_mentions.py b/server/tests/live/test_chat_mentions.py new file mode 100644 index 00000000..07e8cb09 --- /dev/null +++ b/server/tests/live/test_chat_mentions.py @@ -0,0 +1,241 @@ +import asyncio +import uuid + +import pytest +from channels.db import database_sync_to_async + +from tests.live.test_chat import world_communicator +from tests.utils import get_token +from venueless.core.models import User + +# Tests on notification handling accross clients are in test_chat_direct, so we test mostly the mention parsing here + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_notification_on_mention_if_joined(world, chat_room): + sender_token = get_token(world, [], uid="a") + receiver_token = get_token(world, [], uid="b") + channel_id = str(chat_room.channel.id) + async with world_communicator(token=sender_token) as c1, world_communicator( + token=receiver_token + ) as c2: + # Setup. Both clients join, then c2 unsubscribes again ("background tab") + await c1.send_json_to(["chat.join", 123, {"channel": channel_id}]) + await c1.receive_json_from() # Success + await c1.receive_json_from() # Join notification c1 + + await c2.send_json_to(["chat.join", 123, {"channel": channel_id}]) + await c2.receive_json_from() # Success + await c1.receive_json_from() # Join notification c2 + await c2.receive_json_from() # Join notification c2 + + await c2.send_json_to(["chat.unsubscribe", 123, {"channel": channel_id}]) + await c2.receive_json_from() # Success + + user_b = await database_sync_to_async(User.objects.get)(token_id="b") + + # c1 sends a message + await c1.send_json_to( + [ + "chat.send", + 123, + { + "channel": channel_id, + "event_type": "channel.message", + "content": {"type": "text", "body": f"Hello @{user_b.pk}"}, + }, + ] + ) + await c1.receive_json_from() # success + await c1.receive_json_from() # receives message back + + # c2 gets a notification + response = await c2.receive_json_from() + assert response[0] == "chat.notification" + assert channel_id == response[1]["event"]["channel"] + + # c2 gets an unread pointer + response = await c2.receive_json_from() + assert response[0] == "chat.unread_pointers" + assert channel_id in response[1] + + with pytest.raises(asyncio.TimeoutError): + await c1.receive_json_from() # no message to either client + with pytest.raises(asyncio.TimeoutError): + await c2.receive_json_from() # no message to either client + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_no_notification_on_mention_if_rate_limit_exceeded(world, chat_room): + sender_token = get_token(world, [], uid="a") + receiver_token = get_token(world, [], uid="b") + channel_id = str(chat_room.channel.id) + async with world_communicator(token=sender_token) as c1, world_communicator( + token=receiver_token + ) as c2: + # Setup. Both clients join, then c2 unsubscribes again ("background tab") + await c1.send_json_to(["chat.join", 123, {"channel": channel_id}]) + await c1.receive_json_from() # Success + await c1.receive_json_from() # Join notification c1 + + await c2.send_json_to(["chat.join", 123, {"channel": channel_id}]) + await c2.receive_json_from() # Success + await c1.receive_json_from() # Join notification c2 + await c2.receive_json_from() # Join notification c2 + + await c2.send_json_to(["chat.unsubscribe", 123, {"channel": channel_id}]) + await c2.receive_json_from() # Success + + user_b = await database_sync_to_async(User.objects.get)(token_id="b") + + # c1 sends a message + await c1.send_json_to( + [ + "chat.send", + 123, + { + "channel": channel_id, + "event_type": "channel.message", + "content": { + "type": "text", + "body": f"Hello @{user_b.pk} " + + " ".join([f"@{uuid.uuid4()}" for i in range(60)]), + }, + }, + ] + ) + await c1.receive_json_from() # success + await c1.receive_json_from() # receives message back + + # c2 gets an unread pointer + response = await c2.receive_json_from() + assert response[0] == "chat.unread_pointers" + assert channel_id in response[1] + + with pytest.raises(asyncio.TimeoutError): + await c1.receive_json_from() # no message to either client + with pytest.raises(asyncio.TimeoutError): + await c2.receive_json_from() # no message to either client + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_no_notification_on_mention_if_not_joined(world, chat_room): + sender_token = get_token(world, [], uid="a") + receiver_token = get_token(world, [], uid="b") + channel_id = str(chat_room.channel.id) + async with world_communicator(token=sender_token) as c1, world_communicator( + token=receiver_token + ) as c2: + await c1.send_json_to(["chat.join", 123, {"channel": channel_id}]) + await c1.receive_json_from() # Success + await c1.receive_json_from() # Join notification c1 + + user_b = await database_sync_to_async(User.objects.get)(token_id="b") + + # c1 sends a message + await c1.send_json_to( + [ + "chat.send", + 123, + { + "channel": channel_id, + "event_type": "channel.message", + "content": {"type": "text", "body": f"Hello @{user_b.pk}"}, + }, + ] + ) + assert "success" == (await c1.receive_json_from())[0] + assert "chat.mention_warning" == (await c1.receive_json_from())[0] + assert "chat.event" == (await c1.receive_json_from())[0] + + with pytest.raises(asyncio.TimeoutError): + await c1.receive_json_from() # no message to either client + with pytest.raises(asyncio.TimeoutError): + await c2.receive_json_from() # no message to either client + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_notification_on_mention_if_not_joined_volatile_and_permitted( + world, volatile_chat_room +): + sender_token = get_token(world, [], uid="a") + receiver_token = get_token(world, [], uid="b") + channel_id = str(volatile_chat_room.channel.id) + async with world_communicator(token=sender_token) as c1, world_communicator( + token=receiver_token + ) as c2: + # Setup. Both clients join, then c2 unsubscribes again ("background tab") + await c1.send_json_to(["chat.join", 123, {"channel": channel_id}]) + await c1.receive_json_from() # Success + + user_b = await database_sync_to_async(User.objects.get)(token_id="b") + + # c1 sends a message + await c1.send_json_to( + [ + "chat.send", + 123, + { + "channel": channel_id, + "event_type": "channel.message", + "content": {"type": "text", "body": f"Hello @{user_b.pk}"}, + }, + ] + ) + await c1.receive_json_from() # success + await c1.receive_json_from() # receives message back + + # c2 gets a notification + response = await c2.receive_json_from() + assert response[0] == "chat.notification" + assert channel_id == response[1]["event"]["channel"] + + with pytest.raises(asyncio.TimeoutError): + await c1.receive_json_from() # no message to either client + with pytest.raises(asyncio.TimeoutError): + await c2.receive_json_from() # no message to either client + + +@pytest.mark.asyncio +@pytest.mark.django_db +async def test_no_notification_on_mention_if_not_joined_volatile_and_no_permitted( + world, volatile_chat_room +): + volatile_chat_room.trait_grants = {"participant": ["foo"]} + await database_sync_to_async(volatile_chat_room.save)() + sender_token = get_token(world, ["foo"], uid="a") + receiver_token = get_token(world, [], uid="b") + channel_id = str(volatile_chat_room.channel.id) + async with world_communicator(token=sender_token) as c1, world_communicator( + token=receiver_token + ) as c2: + # Setup. Both clients join, then c2 unsubscribes again ("background tab") + await c1.send_json_to(["chat.join", 123, {"channel": channel_id}]) + await c1.receive_json_from() # Success + + user_b = await database_sync_to_async(User.objects.get)(token_id="b") + + # c1 sends a message + await c1.send_json_to( + [ + "chat.send", + 123, + { + "channel": channel_id, + "event_type": "channel.message", + "content": {"type": "text", "body": f"Hello @{user_b.pk}"}, + }, + ] + ) + assert "success" == (await c1.receive_json_from())[0] + assert "chat.mention_warning" == (await c1.receive_json_from())[0] + assert "chat.event" == (await c1.receive_json_from())[0] + + with pytest.raises(asyncio.TimeoutError): + await c1.receive_json_from() # no message to either client + with pytest.raises(asyncio.TimeoutError): + await c2.receive_json_from() # no message to either client diff --git a/server/venueless/core/migrations/0065_chat_mentions.py b/server/venueless/core/migrations/0065_chat_mentions.py new file mode 100644 index 00000000..cf634955 --- /dev/null +++ b/server/venueless/core/migrations/0065_chat_mentions.py @@ -0,0 +1,115 @@ +# Generated by Django 4.2.13 on 2024-07-18 01:31 + +from django.db import migrations, models +import django.db.models.deletion +import venueless.core.models.room +import venueless.core.models.world +import venueless.core.utils.json + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0064_auto_20230805_1737"), + ] + + operations = [ + migrations.AlterField( + model_name="chatevent", + name="content", + field=models.JSONField(), + ), + migrations.AlterField( + model_name="room", + name="module_config", + field=models.JSONField( + default=venueless.core.models.room.empty_module_config, null=True + ), + ), + migrations.AlterField( + model_name="room", + name="schedule_data", + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name="room", + name="trait_grants", + field=models.JSONField( + blank=True, default=venueless.core.models.room.default_grants, null=True + ), + ), + migrations.AlterField( + model_name="user", + name="client_state", + field=models.JSONField(default=dict), + ), + migrations.AlterField( + model_name="user", + name="profile", + field=models.JSONField(), + ), + migrations.AlterField( + model_name="world", + name="config", + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name="world", + name="feature_flags", + field=models.JSONField( + blank=True, default=venueless.core.models.world.default_feature_flags + ), + ), + migrations.AlterField( + model_name="world", + name="roles", + field=models.JSONField( + blank=True, + default=venueless.core.models.world.default_roles, + encoder=venueless.core.utils.json.CustomJSONEncoder, + null=True, + ), + ), + migrations.AlterField( + model_name="world", + name="trait_grants", + field=models.JSONField( + blank=True, + default=venueless.core.models.world.default_grants, + null=True, + ), + ), + migrations.CreateModel( + name="ChatEventNotification", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "chat_event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to="core.chatevent", + ), + ), + ( + "recipient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="chat_notifications", + to="core.user", + ), + ), + ], + options={ + "verbose_name": "Chat Event Notification", + "verbose_name_plural": "Chat Event Notifications", + }, + ), + ] diff --git a/server/venueless/core/models/chat.py b/server/venueless/core/models/chat.py index 35044b0f..91566535 100644 --- a/server/venueless/core/models/chat.py +++ b/server/venueless/core/models/chat.py @@ -86,6 +86,24 @@ class ChatEventReaction(models.Model): related_name="reactions", ) +class ChatEventNotification(models.Model): + recipient = models.ForeignKey( + "User", + on_delete=models.CASCADE, + related_name="chat_notifications", + ) + chat_event = models.ForeignKey( + ChatEvent, + on_delete=models.CASCADE, + related_name="notifications", + ) + + class Meta: + verbose_name = "Chat Event Notification" + verbose_name_plural = "Chat Event Notifications" + + def __str__(self): + return f"Notification for {self.recipient} in event {self.chat_event}" class Membership(models.Model): channel = models.ForeignKey( @@ -116,4 +134,4 @@ def save(self, *args, **kwargs): def delete(self, *args, **kwargs): r = super().delete(*args, **kwargs) self.user.touch() - return r + return r \ No newline at end of file diff --git a/server/venueless/core/services/chat.py b/server/venueless/core/services/chat.py index 41466cb9..e2d6cf33 100644 --- a/server/venueless/core/services/chat.py +++ b/server/venueless/core/services/chat.py @@ -1,3 +1,5 @@ +import re + from contextlib import suppress from channels.db import database_sync_to_async @@ -26,11 +28,15 @@ Membership, User, ) +from ..models.chat import ChatEventNotification from ..permissions import Permission from ..utils.redis import aredis from .bbb import choose_server from .user import get_public_users, user_broadcast +MENTION_RE = re.compile( + r"@([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})" +) @database_sync_to_async def _get_channel(**kwargs): @@ -49,6 +55,17 @@ async def get_channel(**kwargs): return c +def extract_mentioned_user_ids(message: str) -> set: + """ + Extracts user IDs mentioned in a message using a regular expression. + + Args: + message (str): The message to extract user IDs from. + + Returns: + set: A set of mentioned user IDs extracted from the message. + """ + return {match.group(1) for match in MENTION_RE.finditer(message)} class ChatService: def __init__(self, world): self.world = world @@ -95,7 +112,7 @@ def get_channels_for_user(self, user_id, is_volatile=None, is_hidden=False): for m in qs: r = { "id": str(m.channel_id), - "notification_pointer": m.max_id or 0, + "unread_pointer": m.max_id or 0, } if not m.channel.room_id: r["members"] = [ @@ -133,6 +150,34 @@ async def track_unsubscription(self, channel, uid, socket_id): await redis.srem(f"chat:subscriptions:{uid}:{channel}", socket_id) return await redis.scard(f"chat:subscriptions:{uid}:{channel}") + @database_sync_to_async + def filter_mentions( + self, channel: Channel, uids: list, include_all_permitted: bool = False + ) -> set: + """ + Filters user IDs based on their membership or permission in a specified channel. + + Args: + channel (Channel): The channel to filter the users for. + uids (list): List of user IDs to be filtered. + include_all_permitted (bool): If True, includes all users with permission `ROOM_CHAT_READ` in the channel's room. + + Returns: + set: A set of user IDs that are either members of the channel or have the necessary permissions. + """ + if not uids: + return set() + + if include_all_permitted: + permitted_users = User.objects.filter(id__in=uids) + result = {str(u.id) for u in permitted_users if self.world.has_permission( + user=u, permission=Permission.ROOM_CHAT_READ, room=channel.room)} + return result + else: + memberships = Membership.objects.filter(channel=channel, user_id__in=uids) + return {str(m.user_id) for m in memberships} + + @database_sync_to_async def membership_is_volatile(self, channel, uid): try: @@ -200,26 +245,28 @@ def get_events( id__lt=before_id, channel=channel, ) - .prefetch_related("reactions", "sender") + .prefetch_related("reactions") .order_by("-id")[: min(count, 1000)] ) + user_ids = set() + + for e in events: + user_ids.add(str(e.sender.pk)) + + for r in e.reactions.all(): + user_ids.add(str(r.sender.pk)) + + if e.content.get("type") == "text": + user_ids |= extract_mentioned_user_ids(e.content.get("body", "")) + + if users_known_to_client: + user_ids = user_ids - set(users_known_to_client) users = { - str(e.sender.pk): e.sender.serialize_public( + str(u.pk): u.serialize_public( include_admin_info=include_admin_info, trait_badges_map=trait_badges_map ) - for e in events - if str(e.sender.pk) not in users_known_to_client + for u in User.objects.filter(world=self.world, id__in=user_ids) } - for e in events: - for r in e.reactions.all(): - if ( - str(r.sender.pk) not in users - and str(r.sender.pk) not in users_known_to_client - ): - users[str(r.sender.pk)] = r.sender.serialize_public( - include_admin_info=include_admin_info, - trait_badges_map=trait_badges_map, - ) return [e.serialize_public() for e in reversed(events)], users @database_sync_to_async @@ -314,6 +361,61 @@ def add_reaction(self, event, reaction, user): chat_event=event, reaction=reaction, sender=user ) return self._get_event(pk=event.pk).serialize_public() + + def get_notification_counts(self, user_id: int) -> dict: + """ + Retrieves the count of notifications for a given user, grouped by channel ID. + + Args: + user_id (int): The ID of the user. + + Returns: + dict: A dictionary where the keys are channel IDs (as strings) and the values are the count of notifications. + """ + notifications = ChatEventNotification.objects.filter(recipient_id=user_id) + notification_counts = notifications.values("chat_event__channel_id").annotate(count=Count("id")) + return {str(n["chat_event__channel_id"]): n["count"] for n in notification_counts} + + + @database_sync_to_async + def store_notification(self, event_id: int, user_ids: list): + """ + Stores notifications for a given event for multiple users. + + Args: + event_id (int): The ID of the chat event. + user_ids (list): List of user IDs to receive the notification. + + Returns: + None + """ + notifications = [ + ChatEventNotification(chat_event_id=event_id, recipient_id=user_id) + for user_id in user_ids + ] + ChatEventNotification.objects.bulk_create(notifications) + + + @database_sync_to_async + def remove_notifications(self, user_id: int, channel_id: int, max_id: int) -> bool: + """ + Removes notifications for a given user and channel up to a specified maximum event ID. + + Args: + user_id (int): The ID of the user. + channel_id (int): The ID of the channel. + max_id (int): The maximum event ID to consider for deletion. + + Returns: + bool: True if any notifications were deleted, False otherwise. + """ + deleted_count, _ = ChatEventNotification.objects.filter( + chat_event_id__lte=max_id, + chat_event__channel_id=channel_id, + recipient_id=user_id, + ).delete() + return deleted_count > 0 + @database_sync_to_async def get_or_create_direct_channel( @@ -474,4 +576,4 @@ async def enforce_forced_joins(self, user): await redis.sadd( f"chat:unread.notify:{channel.id}", str(user.id), - ) + ) \ No newline at end of file diff --git a/server/venueless/core/services/user.py b/server/venueless/core/services/user.py index 4f569ded..973c8a20 100644 --- a/server/venueless/core/services/user.py +++ b/server/venueless/core/services/user.py @@ -341,7 +341,8 @@ def end_view(view: WorldView, delete=False): LoginResult = namedtuple( - "LoginResult", "user world_config chat_channels exhibition_data view" + "LoginResult", + "user world_config chat_channels chat_notification_counts exhibition_data view", ) @@ -393,6 +394,7 @@ def login( chat_channels=ChatService(world).get_channels_for_user( user.pk, is_volatile=False ), + chat_notification_counts=ChatService(world).get_notification_counts(user.pk), exhibition_data=ExhibitionService(world).get_exhibition_data_for_user(user.pk), view=view, ) @@ -637,4 +639,4 @@ async def user_broadcast(event_type, data, user_id, socket_id): "data": data, "socket": socket_id, }, - ) + ) \ No newline at end of file diff --git a/server/venueless/live/modules/auth.py b/server/venueless/live/modules/auth.py index 8455cff1..94bc1124 100644 --- a/server/venueless/live/modules/auth.py +++ b/server/venueless/live/modules/auth.py @@ -130,6 +130,7 @@ async def login(self, body): "world.config": login_result.world_config, "chat.channels": login_result.chat_channels, "chat.read_pointers": read_pointers, + "chat.notification_counts": login_result.chat_notification_counts, "exhibition": login_result.exhibition_data, "announcements": await get_announcements( world=self.consumer.world.id, moderator=False @@ -602,4 +603,4 @@ def get_user(uid): if user: await self.consumer.send_success(user) else: - await self.consumer.send_error(code="user.not_found") + await self.consumer.send_error(code="user.not_found") \ No newline at end of file diff --git a/server/venueless/live/modules/chat.py b/server/venueless/live/modules/chat.py index b32d6711..964ec861 100644 --- a/server/venueless/live/modules/chat.py +++ b/server/venueless/live/modules/chat.py @@ -5,8 +5,9 @@ import asgiref import emoji -from sentry_sdk import configure_scope +from sentry_sdk import configure_scope +from channels.db import database_sync_to_async from venueless.core.permissions import Permission from venueless.core.services.chat import ChatService, get_channel from venueless.core.services.user import get_public_users @@ -21,6 +22,11 @@ from venueless.live.exceptions import ConsumerException from venueless.live.modules.base import BaseModule from venueless.storage.tasks import retrieve_preview_information +from venueless.core.services.chat import ( + ChatService, + extract_mentioned_user_ids, + get_channel, +) logger = logging.getLogger(__name__) @@ -125,7 +131,7 @@ async def _subscribe(self, volatile=False): return { "state": None, "next_event_id": (last_id) + 1, - "notification_pointer": await self.service.get_highest_nonmember_id_in_channel( + "unread_pointer": await self.service.get_highest_nonmember_id_in_channel( self.channel_id ), "members": await self.service.get_channel_users( @@ -311,6 +317,9 @@ async def mark_read(self, body): ) tr.sadd(f"chat:unread.notify:{self.channel_id}", str(self.consumer.user.id)) await tr.execute() + await self.service.remove_notifications( + self.consumer.user.id, self.channel_id, body.get("id") + ) await self.consumer.send_success() await self.consumer.channel_layer.group_send( GROUP_USER.format(id=self.consumer.user.id), @@ -329,10 +338,18 @@ async def publish_read_pointers(self, body): k.decode(): int(v.decode()) for k, v in redis_read.items() } await self.consumer.send_json(["chat.read_pointers", read_pointers]) + notification_counts = await database_sync_to_async( + self.service.get_notification_counts + )(self.consumer.user.id) + await self.consumer.send_json(["chat.notification_counts", notification_counts]) - @event("notification_pointers") - async def publish_notification_pointers(self, body): - await self.consumer.send_json(["chat.notification_pointers", body.get("data")]) + @event("unread_pointers") + async def publish_unread_pointers(self, body): + await self.consumer.send_json(["chat.unread_pointers", body.get("data")]) + + @event("notification") + async def publish_notification(self, body): + await self.consumer.send_json(["chat.notification", body.get("data")]) @command("send") @channel_action( @@ -429,20 +446,84 @@ async def send(self, body): # Unread notifications async with aredis() as redis: - async def _notify_users(users): + async def _publish_new_pointers(users): for user in users: - if user.decode() == str(self.consumer.user.id): + if user == str(self.consumer.user.id): continue await self.consumer.channel_layer.group_send( GROUP_USER.format(id=user.decode()), { - "type": "chat.notification_pointers", + "type": "chat.unread_pointers", "data": {self.channel_id: event["event_id"]}, }, ) + async def _notify_users(users): + users = [u for u in users if u != str(self.consumer.user.id)] + await self.service.store_notification(event["event_id"], users) + for user in users: + await self.consumer.channel_layer.group_send( + GROUP_USER.format(id=user), + { + "type": "chat.notification", + "data": { + "event": event, + "sender": self.consumer.user.serialize_public( + trait_badges_map=self.consumer.world.config.get( + "trait_badges_map" + ) + ), + }, + }, + ) + + mentioned_users = set() + if not body.get("replaces"): # no notifications for edits: + # Handle mentioned users. We only handle them in rooms, because in DMs everyone is notified anyways. + if content.get("type") == "text": + # Parse all @uuid mentions + mentioned_users = extract_mentioned_user_ids( + content.get("body", "") + ) if self.channel.room: # Normal rooms, possibly big crowds + if mentioned_users and len(mentioned_users) < 50: # prevent abuse + # Filter to people who joined this channel + filtered_mentioned_users = await self.service.filter_mentions( + self.channel, + mentioned_users, + include_all_permitted=self.module_config.get("volatile", False), + ) + if mentioned_users - filtered_mentioned_users: + await self.consumer.send_json( + [ + "chat.mention_warning", + { + "channel": self.channel_id, + "event_id": event["event_id"], + "missed_users": await get_public_users( + self.consumer.world.id, + ids=list( + mentioned_users - filtered_mentioned_users + ), + include_admin_info=await self.consumer.world.has_permission_async( + user=self.consumer.user, + permission=Permission.WORLD_USERS_MANAGE, + ), + trait_badges_map=self.consumer.world.config.get( + "trait_badges_map" + ), + ), + }, + ] + ) + + mentioned_users = filtered_mentioned_users + if mentioned_users: + await _notify_users(mentioned_users) + + # For regular unread notifications, we pop user IDs from the list of users to notify, because once + # they've been notified they don't need a notification again until they sent a new read pointer. # We pop user IDs from the list of users to notify, because once they've been notified they don't need a # notification again until they sent a new read pointer. batch_size = 100 @@ -450,14 +531,44 @@ async def _notify_users(users): users = await redis.spop( f"chat:unread.notify:{self.channel_id}", 100 ) - await _notify_users(users) + await _publish_new_pointers(users) if len(users) < batch_size: break else: # DMs - users = await redis.smembers(f"chat:unread.notify:{self.channel_id}") - await _notify_users(users) + # In DMs, notify everyone. + if not body.get("replaces"): # no notifications for edits: + users = { + u.decode() + for u in await redis.smembers( + f"chat:unread.notify:{self.channel_id}" + ) + } + await _publish_new_pointers(users) + await _notify_users(users) + + if mentioned_users - users: + await self.consumer.send_json( + [ + "chat.mention_warning", + { + "channel": self.channel_id, + "event_id": event["event_id"], + "missed_users": await get_public_users( + self.consumer.world.id, + ids=list(mentioned_users - users), + include_admin_info=await self.consumer.world.has_permission_async( + user=self.consumer.user, + permission=Permission.WORLD_USERS_MANAGE, + ), + trait_badges_map=self.consumer.world.config.get( + "trait_badges_map" + ), + ), + }, + ] + ) if content.get("type") == "text": match = re.search(r"(?Phttps?://[^\s]+)", content.get("body")) @@ -522,6 +633,12 @@ async def publish_event(self, body): user_profiles_required = {data["sender"]} for uids in data["reactions"].values(): user_profiles_required |= set(uids) + + if data["content"].get("type") == "text": + user_profiles_required |= extract_mentioned_user_ids( + data["content"].get("body", "") + ) + user_profiles_required -= self.users_known_to_client data["users"] = {} @@ -606,4 +723,4 @@ async def dispatch_disconnect(self, close_code): for channel in frozenset(self.channels_subscribed): self.channel = channel self.channel_id = channel.pk - await self._unsubscribe() + await self._unsubscribe() \ No newline at end of file diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 74e4f2f6..93cfe2ae 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -42,6 +42,7 @@ "uuid": "^8.3.0", "vue": "^2.6.12", "vue-advanced-cropper": "^0.16.10", + "vue-js-modal": "^2.0.1", "vue-router": "^3.4.3", "vue-slicksort": "^1.2.0", "vue-virtual-scroller": "^1.0.10", @@ -18379,6 +18380,18 @@ "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==", "dev": true }, +"node_modules/vue-js-modal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vue-js-modal/-/vue-js-modal-2.0.1.tgz", + "integrity": "sha512-5FUwsH2zoxRKX4a7wkFAqX0eITCcIMunJDEfIxzHs2bHw9o20+Iqm+uQvBcg1jkzyo1+tVgThR/7NGU8djbD8Q==", + "license": "MIT", + "dependencies": { + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "vue": "^2.6.11" + } + }, "node_modules/vue-loader": { "version": "15.9.8", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.9.8.tgz", @@ -34761,6 +34774,14 @@ "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==", "dev": true }, +"vue-js-modal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/vue-js-modal/-/vue-js-modal-2.0.1.tgz", + "integrity": "sha512-5FUwsH2zoxRKX4a7wkFAqX0eITCcIMunJDEfIxzHs2bHw9o20+Iqm+uQvBcg1jkzyo1+tVgThR/7NGU8djbD8Q==", + "requires": { + "resize-observer-polyfill": "^1.5.1" + } + }, "vue-loader": { "version": "15.9.8", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.9.8.tgz", diff --git a/webapp/package.json b/webapp/package.json index d3ad27df..27a6ba8d 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -48,6 +48,7 @@ "uuid": "^8.3.0", "vue": "^2.6.12", "vue-advanced-cropper": "^0.16.10", + "vue-js-modal": "^2.0.1", "vue-router": "^3.4.3", "vue-slicksort": "^1.2.0", "vue-virtual-scroller": "^1.0.10", diff --git a/webapp/src/components/Chat.vue b/webapp/src/components/Chat.vue index 97a114b9..2f0f217d 100644 --- a/webapp/src/components/Chat.vue +++ b/webapp/src/components/Chat.vue @@ -6,7 +6,11 @@ infinite-scroll(v-if="syncedScroll", :loading="fetchingMessages", @load="fetchMessages") div template(v-for="(message, index) of filteredTimeline") - chat-message(:message="message", :previousMessage="filteredTimeline[index - 1]", :nextMessage="filteredTimeline[index + 1]", :mode="mode", :key="message.event_id") + chat-message(:message="message", :previousMessage="filteredTimeline[index - 1]", :nextMessage="filteredTimeline[index + 1]", :mode="mode", :key="message.event_id", @showUserCard="showUserCard") + .warning(v-if="mergedWarning") + .content + ChatContent(:content="$t('Chat:warning:missed-users', {count: mergedWarning.missed_users.length, missedUsers: mergedWarning.missed_users})", @clickMention="showUserCard") + bunt-icon-button(@click="$store.dispatch('chat/dismissWarnings')") close .chat-input .no-permission(v-if="room && !room.permissions.includes('room:chat.join')") {{ $t('Chat:permission-block:room:chat.join') }} bunt-button(v-else-if="!activeJoinedChannel", @click="join", :tooltip="$t('Chat:join-button:tooltip')") {{ $t('Chat:join-button:label') }} @@ -23,17 +27,19 @@ span.display-name | {{ user.profile.display_name }} span.ui-badge(v-for="badge in user.badges") {{ badge }} - chat-user-card(v-if="selectedUser", ref="avatarCard", :sender="selectedUser", @close="selectedUser = null") + chat-user-card(v-if="userCardUser", ref="avatarCard", :sender="userCardUser", @close="userCardUser = false") bunt-progress-circular(v-else, size="huge", :page="true") \ No newline at end of file diff --git a/webapp/src/components/ChatInput.vue b/webapp/src/components/ChatInput.vue index b0f5da36..e89af454 100644 --- a/webapp/src/components/ChatInput.vue +++ b/webapp/src/components/ChatInput.vue @@ -19,6 +19,14 @@ bunt-input-outline-container.c-chat-input | {{ file.name }} bunt-icon-button#btn-remove-attachment(@click="removeFile(file)") close-circle bunt-progress-circular(size="small" v-if="uploading") + .ui-background-blocker(v-if="autocompleteCoordinates", @click="closeAutocomplete") + .autocomplete-dropdown(:style="autocompleteCoordinates") + template(v-if="autocomplete.options") + template(v-for="option, index of autocomplete.options") + .user(:class="{selected: index === autocomplete.selected}", :title="option.profile.display_name", @mouseover="selectMention(index)", @click.stop="handleMention") + avatar(:user="option", :size="24") + .name {{ option.profile.display_name }} + bunt-progress-circular(v-else, size="large", :page="true")