From 55d1eae24cc3b0d21e5de10ce38da13152bd1722 Mon Sep 17 00:00:00 2001 From: frankystone <> Date: Thu, 20 Nov 2025 17:43:40 +0100 Subject: [PATCH 01/24] added notification forum_mention --- notification/models.py | 2 +- .../notification/forum_mention/full.txt | 11 +++++++++ pybb/management/pybb_notifications.py | 9 +++++++ pybb/views.py | 24 +++++++++++++++++++ templates/django_messages/compose.html | 4 ++-- 5 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 notification/templates/notification/forum_mention/full.txt diff --git a/notification/models.py b/notification/models.py index 2ccd9719..9ce775e2 100644 --- a/notification/models.py +++ b/notification/models.py @@ -85,7 +85,7 @@ class Meta: def get_notification_setting(user, notice_type, medium): - """Return NotceSetting for a specific user. If a NoticeSetting of + """Return NoticeSetting for a specific user. If a NoticeSetting of given NoticeType didn't exist for given user, a NoticeSetting is created. If a new NoticeSetting is created, the field 'default' of a NoticeType diff --git a/notification/templates/notification/forum_mention/full.txt b/notification/templates/notification/forum_mention/full.txt new file mode 100644 index 00000000..11b40c94 --- /dev/null +++ b/notification/templates/notification/forum_mention/full.txt @@ -0,0 +1,11 @@ +{% autoescape off %} +{% load i18n %}Your name was mentioned in a forum post by "{{ user }}": + +"{{ user }}" wrote: + +{{ post.body }} +{% blocktrans with post.get_absolute_url as post_url and topic.get_absolute_url as topic_url %} +------------------------- +Link to post: https://{{ current_site }}{{ post_url }} +Link to topic: https://{{ current_site }}{{ topic_url }} +{% endblocktrans %}{% endautoescape %} diff --git a/pybb/management/pybb_notifications.py b/pybb/management/pybb_notifications.py index f9859216..2b174981 100644 --- a/pybb/management/pybb_notifications.py +++ b/pybb/management/pybb_notifications.py @@ -24,6 +24,15 @@ def create_notice_types(sender, **kwargs): ), default=0, ) + notification.create_notice_type( + "forum_mention", + _("Your name was mentioned"), + _( + "someone has mentioned your name with '@name' in a post" + ), + default=1, + ) + except ImportError: print("Skipping creation of NoticeTypes as notification app not found") diff --git a/pybb/views.py b/pybb/views.py index e3e335fe..a61a5cb3 100644 --- a/pybb/views.py +++ b/pybb/views.py @@ -32,6 +32,7 @@ from mainpage.wl_utils import get_pagination import math from mainpage.validators import check_utf8mb3_preview +import re try: from notification import models as notification @@ -296,6 +297,29 @@ def add_post_ctx(request, forum_id, topic_id): "forum_new_post", {"post": post, "topic": topic, "user": post.user}, ) + # Handle mentions with @username + mention_re = re.compile(r'@(\S+)') + mentioned_users = mention_re.findall(post.body) + subscribers = [] + for username in mentioned_users: + try: + user_obj = User.objects.get(username=username) + + notice_type = notification.NoticeType.objects.get( + label="forum_mention" + ) + if notification.get_notification_setting( + user_obj, notice_type, "1").send: + + subscribers.append(user_obj) + + notification.send( + subscribers, "forum_mention", + {"post": post, "user": post.user} + ) + except User.DoesNotExist: + pass + return HttpResponseRedirect(post.get_absolute_url()) diff --git a/templates/django_messages/compose.html b/templates/django_messages/compose.html index 9a7fb89a..5a477dc6 100644 --- a/templates/django_messages/compose.html +++ b/templates/django_messages/compose.html @@ -1,4 +1,4 @@ -{% extends "django_messages/base.html" %} +{% extends "django_messages/base.html" %} {% load i18n %} {% load wl_extras %} @@ -13,7 +13,7 @@ source: '/messages/django_messages_wl/get_usernames/', minLength: 3, }); -}); + }); {{ block.super}}{% endblock %} From 83ab406a044cfcadb2004939862e018124c9939b Mon Sep 17 00:00:00 2001 From: frankystone <> Date: Thu, 20 Nov 2025 18:01:34 +0100 Subject: [PATCH 02/24] strip out notification handling in extra file --- pybb/notifications.py | 78 +++++++++++++++++++++++++++++++++++++++++ pybb/views.py | 80 ++----------------------------------------- 2 files changed, 80 insertions(+), 78 deletions(-) create mode 100644 pybb/notifications.py diff --git a/pybb/notifications.py b/pybb/notifications.py new file mode 100644 index 00000000..93a772d6 --- /dev/null +++ b/pybb/notifications.py @@ -0,0 +1,78 @@ +import re + +from django.contrib.auth.models import User +from django.db.models import Q + +from notification import models as notification +from pybb import settings as pybb_settings + + +def notify(request, topic, post): + if not topic: + # Inform subscribers of a new topic + if post.topic.forum.category.internal: + # Inform only users which have the permission to enter the + # internal forum and superusers. Those users have to: + # - enable 'forum_new_topic' in the notification settings, or + # - subscribe to an existing topic + subscribers = User.objects.filter( + Q(groups__permissions__codename=pybb_settings.INTERNAL_PERM) + | Q(user_permissions__codename=pybb_settings.INTERNAL_PERM) + ).exclude(username=request.user.username) + superusers = User.objects.filter(is_superuser=True).exclude( + username=request.user.username + ) + # Combine the querysets, excluding double entrys. + subscribers = subscribers.union(superusers) + else: + # Inform normal users + subscribers = notification.get_observers_for( + "forum_new_topic", excl_user=request.user + ) + + notification.send( + subscribers, + "forum_new_topic", + {"topic": post.topic, "post": post, "user": post.topic.user}, + ) + # Topics author is subscriber for all new posts in his topic + post.topic.subscribers.add(request.user) + + else: + # Handle auto subscriptions to topics + notice_type = notification.NoticeType.objects.get( + label="forum_auto_subscribe" + ) + notice_setting = notification.get_notification_setting( + post.user, notice_type, "1" + ) + if notice_setting.send: + post.topic.subscribers.add(request.user) + + # Send mails about a new post to topic subscribers + notification.send( + post.topic.subscribers.exclude(username=post.user), + "forum_new_post", + {"post": post, "topic": topic, "user": post.user}, + ) + # Handle mentions with @username + mention_re = re.compile(r'@(\S+\b)') + mentioned_users = mention_re.findall(post.body) + subscribers = [] + for username in mentioned_users: + try: + user_obj = User.objects.get(username=username) + + notice_type = notification.NoticeType.objects.get( + label="forum_mention" + ) + if notification.get_notification_setting( + user_obj, notice_type, "1").send: + subscribers.append(user_obj) + + notification.send( + subscribers, "forum_mention", + {"post": post, "user": post.user} + ) + except User.DoesNotExist: + pass diff --git a/pybb/views.py b/pybb/views.py index a61a5cb3..d82cc96c 100644 --- a/pybb/views.py +++ b/pybb/views.py @@ -4,7 +4,6 @@ from django.conf import settings from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User -from django.db.models import Q from django.http import HttpResponseRedirect, HttpResponse, Http404 from django.shortcuts import get_object_or_404 from django.shortcuts import redirect @@ -32,12 +31,7 @@ from mainpage.wl_utils import get_pagination import math from mainpage.validators import check_utf8mb3_preview -import re - -try: - from notification import models as notification -except ImportError: - notification = None +from pybb.notifications import notify def index_ctx(request): @@ -249,77 +243,7 @@ def add_post_ctx(request, forum_id, topic_id): post.save(update_fields=["hidden"]) return HttpResponseRedirect("/moderated/") - if notification: - if not topic: - # Inform subscribers of a new topic - if post.topic.forum.category.internal: - # Inform only users which have the permission to enter the - # internal forum and superusers. Those users have to: - # - enable 'forum_new_topic' in the notification settings, or - # - subscribe to an existing topic - subscribers = User.objects.filter( - Q(groups__permissions__codename=pybb_settings.INTERNAL_PERM) - | Q(user_permissions__codename=pybb_settings.INTERNAL_PERM) - ).exclude(username=request.user.username) - superusers = User.objects.filter(is_superuser=True).exclude( - username=request.user.username - ) - # Combine the querysets, excluding double entrys. - subscribers = subscribers.union(superusers) - else: - # Inform normal users - subscribers = notification.get_observers_for( - "forum_new_topic", excl_user=request.user - ) - - notification.send( - subscribers, - "forum_new_topic", - {"topic": post.topic, "post": post, "user": post.topic.user}, - ) - # Topics author is subscriber for all new posts in his topic - post.topic.subscribers.add(request.user) - - else: - # Handle auto subscriptions to topics - notice_type = notification.NoticeType.objects.get( - label="forum_auto_subscribe" - ) - notice_setting = notification.get_notification_setting( - post.user, notice_type, "1" - ) - if notice_setting.send: - post.topic.subscribers.add(request.user) - - # Send mails about a new post to topic subscribers - notification.send( - post.topic.subscribers.exclude(username=post.user), - "forum_new_post", - {"post": post, "topic": topic, "user": post.user}, - ) - # Handle mentions with @username - mention_re = re.compile(r'@(\S+)') - mentioned_users = mention_re.findall(post.body) - subscribers = [] - for username in mentioned_users: - try: - user_obj = User.objects.get(username=username) - - notice_type = notification.NoticeType.objects.get( - label="forum_mention" - ) - if notification.get_notification_setting( - user_obj, notice_type, "1").send: - - subscribers.append(user_obj) - - notification.send( - subscribers, "forum_mention", - {"post": post, "user": post.user} - ) - except User.DoesNotExist: - pass - + notify(request, topic, post) return HttpResponseRedirect(post.get_absolute_url()) From 1a747e238675875a9e6d80a2c815e185ced346ac Mon Sep 17 00:00:00 2001 From: frankystone <> Date: Thu, 20 Nov 2025 19:56:09 +0100 Subject: [PATCH 03/24] send either new post or mention, not both --- .../notification/forum_mention/full.txt | 2 +- pybb/notifications.py | 49 ++++++++++++------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/notification/templates/notification/forum_mention/full.txt b/notification/templates/notification/forum_mention/full.txt index 11b40c94..6e1ce46e 100644 --- a/notification/templates/notification/forum_mention/full.txt +++ b/notification/templates/notification/forum_mention/full.txt @@ -1,5 +1,5 @@ {% autoescape off %} -{% load i18n %}Your name was mentioned in a forum post by "{{ user }}": +{% load i18n %}Your name was mentioned in a forum post by "{{ user }}" on topic "{{ topic }}"": "{{ user }}" wrote: diff --git a/pybb/notifications.py b/pybb/notifications.py index 93a772d6..adb4b6f3 100644 --- a/pybb/notifications.py +++ b/pybb/notifications.py @@ -6,6 +6,8 @@ from notification import models as notification from pybb import settings as pybb_settings +MENTION_RE = re.compile(r'@(\S+\b)') + def notify(request, topic, post): if not topic: @@ -39,7 +41,10 @@ def notify(request, topic, post): post.topic.subscribers.add(request.user) else: - # Handle auto subscriptions to topics + # Handle auto subscriptions to topics and mentions with @username + # Either send a mail for new post or mention, not both + + # add post subscribers notice_type = notification.NoticeType.objects.get( label="forum_auto_subscribe" ) @@ -49,30 +54,36 @@ def notify(request, topic, post): if notice_setting.send: post.topic.subscribers.add(request.user) - # Send mails about a new post to topic subscribers - notification.send( - post.topic.subscribers.exclude(username=post.user), - "forum_new_post", - {"post": post, "topic": topic, "user": post.user}, - ) - # Handle mentions with @username - mention_re = re.compile(r'@(\S+\b)') - mentioned_users = mention_re.findall(post.body) - subscribers = [] - for username in mentioned_users: + # mentions + mentioned_names = MENTION_RE.findall(post.body) + mentioned_users = [] + for username in mentioned_names: try: user_obj = User.objects.get(username=username) notice_type = notification.NoticeType.objects.get( label="forum_mention" ) - if notification.get_notification_setting( - user_obj, notice_type, "1").send: - subscribers.append(user_obj) - notification.send( - subscribers, "forum_mention", - {"post": post, "user": post.user} - ) + if notification.get_notification_setting(user_obj, notice_type, "1").send: + mentioned_users.append(user_obj) + except User.DoesNotExist: pass + + # Remove mentioned users from topic subscribers + topic_subscribers = post.topic.subscribers.exclude( + username=post.user).exclude( + username__in=mentioned_users) + + # finally send the mails + notification.send(mentioned_users, "forum_mention", + {"post": post, "topic": topic, "user": post.user} + ) + + # Send mails about a new post to topic subscribers + notification.send( + topic_subscribers, + "forum_new_post", + {"post": post, "topic": topic, "user": post.user}, + ) From db92c4790a1360b370c0705a743d46b39cb1516b Mon Sep 17 00:00:00 2001 From: frankystone <> Date: Fri, 21 Nov 2025 17:44:30 +0100 Subject: [PATCH 04/24] if mentione do not send notice about new topic; refactoring --- pybb/notifications.py | 71 +++++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/pybb/notifications.py b/pybb/notifications.py index adb4b6f3..6d8a61b5 100644 --- a/pybb/notifications.py +++ b/pybb/notifications.py @@ -6,10 +6,35 @@ from notification import models as notification from pybb import settings as pybb_settings -MENTION_RE = re.compile(r'@(\S+\b)') +MENTION_RE = re.compile(r'@([\w.@+\-_]+)') def notify(request, topic, post): + + # mentions + def _get_mentions(): + mentioned_names = MENTION_RE.findall(post.body) + mentioned_users = [] + for username in mentioned_names: + try: + user_obj = User.objects.get(username=username) + + notice_type = notification.NoticeType.objects.get( + label="forum_mention" + ) + + if notification.get_notification_setting(user_obj, notice_type, "1").send: + mentioned_users.append(user_obj) + + except User.DoesNotExist: + pass + return mentioned_users + + def _inform_mentioned(mentioned): + notification.send(mentioned, "forum_mention", + {"post": post, "topic": topic, "user": post.user} + ) + if not topic: # Inform subscribers of a new topic if post.topic.forum.category.internal: @@ -27,24 +52,30 @@ def notify(request, topic, post): # Combine the querysets, excluding double entrys. subscribers = subscribers.union(superusers) else: - # Inform normal users + # Normal users subscribers = notification.get_observers_for( "forum_new_topic", excl_user=request.user ) + mentions = _get_mentions() + + # remove mentioned user from subscribers + new_subscribers = set(subscribers) - set(mentions) + + # send the mails + _inform_mentioned(mentions) + notification.send( - subscribers, + new_subscribers, "forum_new_topic", {"topic": post.topic, "post": post, "user": post.topic.user}, ) + # Topics author is subscriber for all new posts in his topic post.topic.subscribers.add(request.user) else: - # Handle auto subscriptions to topics and mentions with @username - # Either send a mail for new post or mention, not both - - # add post subscribers + # Inform users who auto subscribed to topics notice_type = notification.NoticeType.objects.get( label="forum_auto_subscribe" ) @@ -54,32 +85,14 @@ def notify(request, topic, post): if notice_setting.send: post.topic.subscribers.add(request.user) - # mentions - mentioned_names = MENTION_RE.findall(post.body) - mentioned_users = [] - for username in mentioned_names: - try: - user_obj = User.objects.get(username=username) - - notice_type = notification.NoticeType.objects.get( - label="forum_mention" - ) - - if notification.get_notification_setting(user_obj, notice_type, "1").send: - mentioned_users.append(user_obj) - - except User.DoesNotExist: - pass + mentions = _get_mentions() # Remove mentioned users from topic subscribers - topic_subscribers = post.topic.subscribers.exclude( - username=post.user).exclude( - username__in=mentioned_users) + topic_subscribers = set(post.topic.subscribers.exclude( + username=post.user)) - set(mentions) # finally send the mails - notification.send(mentioned_users, "forum_mention", - {"post": post, "topic": topic, "user": post.user} - ) + _inform_mentioned(mentions) # Send mails about a new post to topic subscribers notification.send( From 3653fe552385d2bc588ae6b6f737b44a6c9c4b19 Mon Sep 17 00:00:00 2001 From: frankystone <> Date: Fri, 21 Nov 2025 22:56:56 +0100 Subject: [PATCH 05/24] set sending e-mails for mentions as default --- pybb/management/pybb_notifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybb/management/pybb_notifications.py b/pybb/management/pybb_notifications.py index 2b174981..43f4c550 100644 --- a/pybb/management/pybb_notifications.py +++ b/pybb/management/pybb_notifications.py @@ -30,7 +30,7 @@ def create_notice_types(sender, **kwargs): _( "someone has mentioned your name with '@name' in a post" ), - default=1, + default=2, ) From 4b62600f235c8a0aa46999d6de3a7d13f330366c Mon Sep 17 00:00:00 2001 From: frankystone <> Date: Fri, 21 Nov 2025 23:42:07 +0100 Subject: [PATCH 06/24] doc strings and comments --- pybb/notifications.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/pybb/notifications.py b/pybb/notifications.py index 6d8a61b5..b3778a12 100644 --- a/pybb/notifications.py +++ b/pybb/notifications.py @@ -10,12 +10,23 @@ def notify(request, topic, post): + """"Send mails for mentions, topic subscribers and users who are auto subscribers. + + - topic subscribers are all users who clicked 'subscribe' to a topic and the topic author + himself + - auto subscribers are all who enabled 'auto subscriptions' and wrote a post in a topic + - mentioned are all whose name is mentioned like @username in a post + mentioning takes precedence over all. That is if a user is mentioned he will get only one + email for mentioning and no email for new topic or new post. + """ - # mentions def _get_mentions(): + """Return usernames which are mentioned in a post like @username.""" + mentioned_names = MENTION_RE.findall(post.body) mentioned_users = [] for username in mentioned_names: + # Make sure this is an existing user try: user_obj = User.objects.get(username=username) @@ -30,8 +41,8 @@ def _get_mentions(): pass return mentioned_users - def _inform_mentioned(mentioned): - notification.send(mentioned, "forum_mention", + def _inform_mentioned(mentions): + notification.send(mentions, "forum_mention", {"post": post, "topic": topic, "user": post.user} ) @@ -41,7 +52,7 @@ def _inform_mentioned(mentioned): # Inform only users which have the permission to enter the # internal forum and superusers. Those users have to: # - enable 'forum_new_topic' in the notification settings, or - # - subscribe to an existing topic + # - subscribed to an existing topic subscribers = User.objects.filter( Q(groups__permissions__codename=pybb_settings.INTERNAL_PERM) | Q(user_permissions__codename=pybb_settings.INTERNAL_PERM) @@ -49,7 +60,7 @@ def _inform_mentioned(mentioned): superusers = User.objects.filter(is_superuser=True).exclude( username=request.user.username ) - # Combine the querysets, excluding double entrys. + # Combine the query sets, excluding double entries. subscribers = subscribers.union(superusers) else: # Normal users @@ -59,12 +70,11 @@ def _inform_mentioned(mentioned): mentions = _get_mentions() - # remove mentioned user from subscribers + # Remove mentioned users from subscribers new_subscribers = set(subscribers) - set(mentions) - # send the mails + # Send the mails _inform_mentioned(mentions) - notification.send( new_subscribers, "forum_new_topic", @@ -91,9 +101,8 @@ def _inform_mentioned(mentioned): topic_subscribers = set(post.topic.subscribers.exclude( username=post.user)) - set(mentions) - # finally send the mails + # Finally send the mails _inform_mentioned(mentions) - # Send mails about a new post to topic subscribers notification.send( topic_subscribers, From e43393001c902e75f3b9af4145e7dee39ba58ec3 Mon Sep 17 00:00:00 2001 From: frankystone <> Date: Fri, 21 Nov 2025 23:48:53 +0100 Subject: [PATCH 07/24] clean at least one shadowd name --- pybb/notifications.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pybb/notifications.py b/pybb/notifications.py index b3778a12..6dc49e99 100644 --- a/pybb/notifications.py +++ b/pybb/notifications.py @@ -10,7 +10,7 @@ def notify(request, topic, post): - """"Send mails for mentions, topic subscribers and users who are auto subscribers. + """Send mails for mentions, topic subscribers and users who are auto subscribers. - topic subscribers are all users who clicked 'subscribe' to a topic and the topic author himself @@ -41,8 +41,8 @@ def _get_mentions(): pass return mentioned_users - def _inform_mentioned(mentions): - notification.send(mentions, "forum_mention", + def _inform_mentioned(mentioned): + notification.send(mentioned, "forum_mention", {"post": post, "topic": topic, "user": post.user} ) From 1d23ea7ed4d1a4039172fd6e37774440577df492 Mon Sep 17 00:00:00 2001 From: frankystone <> Date: Sat, 22 Nov 2025 11:47:57 +0100 Subject: [PATCH 08/24] fixed display of topic --- pybb/notifications.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pybb/notifications.py b/pybb/notifications.py index 6dc49e99..1b3f3214 100644 --- a/pybb/notifications.py +++ b/pybb/notifications.py @@ -39,11 +39,12 @@ def _get_mentions(): except User.DoesNotExist: pass + return mentioned_users def _inform_mentioned(mentioned): notification.send(mentioned, "forum_mention", - {"post": post, "topic": topic, "user": post.user} + {"post": post, "topic": post.topic, "user": post.user} ) if not topic: From cd2f04293e4ccf9d5d11d7e77a74337023e94f38 Mon Sep 17 00:00:00 2001 From: Widelands Bunnybot Date: Sat, 22 Nov 2025 12:00:28 +0100 Subject: [PATCH 09/24] 2 files were automatically formatted. --- pybb/management/pybb_notifications.py | 5 +---- pybb/notifications.py | 27 ++++++++++++++------------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/pybb/management/pybb_notifications.py b/pybb/management/pybb_notifications.py index 43f4c550..04d0f705 100644 --- a/pybb/management/pybb_notifications.py +++ b/pybb/management/pybb_notifications.py @@ -27,12 +27,9 @@ def create_notice_types(sender, **kwargs): notification.create_notice_type( "forum_mention", _("Your name was mentioned"), - _( - "someone has mentioned your name with '@name' in a post" - ), + _("someone has mentioned your name with '@name' in a post"), default=2, ) - except ImportError: print("Skipping creation of NoticeTypes as notification app not found") diff --git a/pybb/notifications.py b/pybb/notifications.py index 1b3f3214..3e950bdf 100644 --- a/pybb/notifications.py +++ b/pybb/notifications.py @@ -6,7 +6,7 @@ from notification import models as notification from pybb import settings as pybb_settings -MENTION_RE = re.compile(r'@([\w.@+\-_]+)') +MENTION_RE = re.compile(r"@([\w.@+\-_]+)") def notify(request, topic, post): @@ -30,11 +30,11 @@ def _get_mentions(): try: user_obj = User.objects.get(username=username) - notice_type = notification.NoticeType.objects.get( - label="forum_mention" - ) + notice_type = notification.NoticeType.objects.get(label="forum_mention") - if notification.get_notification_setting(user_obj, notice_type, "1").send: + if notification.get_notification_setting( + user_obj, notice_type, "1" + ).send: mentioned_users.append(user_obj) except User.DoesNotExist: @@ -43,9 +43,11 @@ def _get_mentions(): return mentioned_users def _inform_mentioned(mentioned): - notification.send(mentioned, "forum_mention", - {"post": post, "topic": post.topic, "user": post.user} - ) + notification.send( + mentioned, + "forum_mention", + {"post": post, "topic": post.topic, "user": post.user}, + ) if not topic: # Inform subscribers of a new topic @@ -87,9 +89,7 @@ def _inform_mentioned(mentioned): else: # Inform users who auto subscribed to topics - notice_type = notification.NoticeType.objects.get( - label="forum_auto_subscribe" - ) + notice_type = notification.NoticeType.objects.get(label="forum_auto_subscribe") notice_setting = notification.get_notification_setting( post.user, notice_type, "1" ) @@ -99,8 +99,9 @@ def _inform_mentioned(mentioned): mentions = _get_mentions() # Remove mentioned users from topic subscribers - topic_subscribers = set(post.topic.subscribers.exclude( - username=post.user)) - set(mentions) + topic_subscribers = set( + post.topic.subscribers.exclude(username=post.user) + ) - set(mentions) # Finally send the mails _inform_mentioned(mentions) From b8e9e832a8ad7e3c539af75f2b4f18c22c2a3e61 Mon Sep 17 00:00:00 2001 From: janus Date: Sat, 22 Nov 2025 12:55:39 +0100 Subject: [PATCH 10/24] "_" removed because it is already included in \w \w -> [a-zA-Z0-9_] https://docs.python.org/3/library/re.html#index-34 --- pybb/notifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybb/notifications.py b/pybb/notifications.py index 3e950bdf..780e6188 100644 --- a/pybb/notifications.py +++ b/pybb/notifications.py @@ -6,7 +6,7 @@ from notification import models as notification from pybb import settings as pybb_settings -MENTION_RE = re.compile(r"@([\w.@+\-_]+)") +MENTION_RE = re.compile(r"@([\w.@+\-]+)") def notify(request, topic, post): From 88992904a2e480ce54033051930e6499bf4e9c5d Mon Sep 17 00:00:00 2001 From: frankystone <> Date: Wed, 26 Nov 2025 14:30:45 +0100 Subject: [PATCH 11/24] fist working solution for mentions in textarea --- .editorconfig | 2 +- pybb/static/js/tribute/LICENSE.txt | 23 + pybb/static/js/tribute/tribute.css | 32 + pybb/static/js/tribute/tribute.js | 1853 ++++++++++++++++++++++++++++ pybb/static/js/tribute_mention.js | 104 ++ pybb/templates/pybb/post_form.html | 3 + 6 files changed, 2016 insertions(+), 1 deletion(-) create mode 100644 pybb/static/js/tribute/LICENSE.txt create mode 100644 pybb/static/js/tribute/tribute.css create mode 100644 pybb/static/js/tribute/tribute.js create mode 100644 pybb/static/js/tribute_mention.js diff --git a/.editorconfig b/.editorconfig index ccd77c54..115ece69 100644 --- a/.editorconfig +++ b/.editorconfig @@ -22,7 +22,7 @@ indent_size = 4 [*.js] indent_style = space -indent_size = 2 +indent_size = 4 [*.css] indent_style = space diff --git a/pybb/static/js/tribute/LICENSE.txt b/pybb/static/js/tribute/LICENSE.txt new file mode 100644 index 00000000..a324412f --- /dev/null +++ b/pybb/static/js/tribute/LICENSE.txt @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) 2017-2020 ZURB, Inc. +Copyright (c) 2014 Jeff Collins +Copyright (c) 2012 Matt York + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pybb/static/js/tribute/tribute.css b/pybb/static/js/tribute/tribute.css new file mode 100644 index 00000000..3268c09a --- /dev/null +++ b/pybb/static/js/tribute/tribute.css @@ -0,0 +1,32 @@ +.tribute-container { + position: absolute; + top: 0; + left: 0; + height: auto; + overflow: auto; + display: block; + z-index: 999999; +} +.tribute-container ul { + margin: 0; + margin-top: 2px; + padding: 0; + list-style: none; + background: #efefef; +} +.tribute-container li { + padding: 5px 5px; + cursor: pointer; +} +.tribute-container li.highlight { + background: #ddd; +} +.tribute-container li span { + font-weight: bold; +} +.tribute-container li.no-match { + cursor: default; +} +.tribute-container .menu-highlighted { + font-weight: bold; +} \ No newline at end of file diff --git a/pybb/static/js/tribute/tribute.js b/pybb/static/js/tribute/tribute.js new file mode 100644 index 00000000..d5f1629f --- /dev/null +++ b/pybb/static/js/tribute/tribute.js @@ -0,0 +1,1853 @@ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global = global || self, global.Tribute = factory()); +}(this, (function () { 'use strict'; + + function _classCallCheck(instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + } + + function _defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + function _createClass(Constructor, protoProps, staticProps) { + if (protoProps) _defineProperties(Constructor.prototype, protoProps); + if (staticProps) _defineProperties(Constructor, staticProps); + return Constructor; + } + + function _slicedToArray(arr, i) { + return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); + } + + function _arrayWithHoles(arr) { + if (Array.isArray(arr)) return arr; + } + + function _iterableToArrayLimit(arr, i) { + if (typeof Symbol === "undefined" || !(Symbol.iterator in Object(arr))) return; + var _arr = []; + var _n = true; + var _d = false; + var _e = undefined; + + try { + for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { + _arr.push(_s.value); + + if (i && _arr.length === i) break; + } + } catch (err) { + _d = true; + _e = err; + } finally { + try { + if (!_n && _i["return"] != null) _i["return"](); + } finally { + if (_d) throw _e; + } + } + + return _arr; + } + + function _unsupportedIterableToArray(o, minLen) { + if (!o) return; + if (typeof o === "string") return _arrayLikeToArray(o, minLen); + var n = Object.prototype.toString.call(o).slice(8, -1); + if (n === "Object" && o.constructor) n = o.constructor.name; + if (n === "Map" || n === "Set") return Array.from(n); + if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); + } + + function _arrayLikeToArray(arr, len) { + if (len == null || len > arr.length) len = arr.length; + + for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; + + return arr2; + } + + function _nonIterableRest() { + throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); + } + + if (!Array.prototype.find) { + Array.prototype.find = function (predicate) { + if (this === null) { + throw new TypeError('Array.prototype.find called on null or undefined'); + } + + if (typeof predicate !== 'function') { + throw new TypeError('predicate must be a function'); + } + + var list = Object(this); + var length = list.length >>> 0; + var thisArg = arguments[1]; + var value; + + for (var i = 0; i < length; i++) { + value = list[i]; + + if (predicate.call(thisArg, value, i, list)) { + return value; + } + } + + return undefined; + }; + } + + if (window && typeof window.CustomEvent !== "function") { + var CustomEvent$1 = function CustomEvent(event, params) { + params = params || { + bubbles: false, + cancelable: false, + detail: undefined + }; + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail); + return evt; + }; + + if (typeof window.Event !== 'undefined') { + CustomEvent$1.prototype = window.Event.prototype; + } + + window.CustomEvent = CustomEvent$1; + } + + var TributeEvents = /*#__PURE__*/function () { + function TributeEvents(tribute) { + _classCallCheck(this, TributeEvents); + + this.tribute = tribute; + this.tribute.events = this; + } + + _createClass(TributeEvents, [{ + key: "bind", + value: function bind(element) { + element.boundKeydown = this.keydown.bind(element, this); + element.boundKeyup = this.keyup.bind(element, this); + element.boundInput = this.input.bind(element, this); + element.addEventListener("keydown", element.boundKeydown, false); + element.addEventListener("keyup", element.boundKeyup, false); + element.addEventListener("input", element.boundInput, false); + } + }, { + key: "unbind", + value: function unbind(element) { + element.removeEventListener("keydown", element.boundKeydown, false); + element.removeEventListener("keyup", element.boundKeyup, false); + element.removeEventListener("input", element.boundInput, false); + delete element.boundKeydown; + delete element.boundKeyup; + delete element.boundInput; + } + }, { + key: "keydown", + value: function keydown(instance, event) { + if (instance.shouldDeactivate(event)) { + instance.tribute.isActive = false; + instance.tribute.hideMenu(); + } + + var element = this; + instance.commandEvent = false; + TributeEvents.keys().forEach(function (o) { + if (o.key === event.keyCode) { + instance.commandEvent = true; + instance.callbacks()[o.value.toLowerCase()](event, element); + } + }); + } + }, { + key: "input", + value: function input(instance, event) { + instance.inputEvent = true; + instance.keyup.call(this, instance, event); + } + }, { + key: "click", + value: function click(instance, event) { + var tribute = instance.tribute; + + if (tribute.menu && tribute.menu.contains(event.target)) { + var li = event.target; + event.preventDefault(); + event.stopPropagation(); + + while (li.nodeName.toLowerCase() !== "li") { + li = li.parentNode; + + if (!li || li === tribute.menu) { + throw new Error("cannot find the
  • container for the click"); + } + } + + tribute.selectItemAtIndex(li.getAttribute("data-index"), event); + tribute.hideMenu(); // TODO: should fire with externalTrigger and target is outside of menu + } else if (tribute.current.element && !tribute.current.externalTrigger) { + tribute.current.externalTrigger = false; + setTimeout(function () { + return tribute.hideMenu(); + }); + } + } + }, { + key: "keyup", + value: function keyup(instance, event) { + if (instance.inputEvent) { + instance.inputEvent = false; + } + + instance.updateSelection(this); + if (event.keyCode === 27) return; + + if (!instance.tribute.allowSpaces && instance.tribute.hasTrailingSpace) { + instance.tribute.hasTrailingSpace = false; + instance.commandEvent = true; + instance.callbacks()["space"](event, this); + return; + } + + if (!instance.tribute.isActive) { + if (instance.tribute.autocompleteMode) { + instance.callbacks().triggerChar(event, this, ""); + } else { + var keyCode = instance.getKeyCode(instance, this, event); + if (isNaN(keyCode) || !keyCode) return; + var trigger = instance.tribute.triggers().find(function (trigger) { + return trigger.charCodeAt(0) === keyCode; + }); + + if (typeof trigger !== "undefined") { + instance.callbacks().triggerChar(event, this, trigger); + } + } + } + + if (instance.tribute.current.mentionText.length < instance.tribute.current.collection.menuShowMinLength) { + return; + } + + if ((instance.tribute.current.trigger || instance.tribute.autocompleteMode) && instance.commandEvent === false || instance.tribute.isActive && event.keyCode === 8) { + instance.tribute.showMenuFor(this, true); + } + } + }, { + key: "shouldDeactivate", + value: function shouldDeactivate(event) { + if (!this.tribute.isActive) return false; + + if (this.tribute.current.mentionText.length === 0) { + var eventKeyPressed = false; + TributeEvents.keys().forEach(function (o) { + if (event.keyCode === o.key) eventKeyPressed = true; + }); + return !eventKeyPressed; + } + + return false; + } + }, { + key: "getKeyCode", + value: function getKeyCode(instance, el, event) { + + var tribute = instance.tribute; + var info = tribute.range.getTriggerInfo(false, tribute.hasTrailingSpace, true, tribute.allowSpaces, tribute.autocompleteMode); + + if (info) { + return info.mentionTriggerChar.charCodeAt(0); + } else { + return false; + } + } + }, { + key: "updateSelection", + value: function updateSelection(el) { + this.tribute.current.element = el; + var info = this.tribute.range.getTriggerInfo(false, this.tribute.hasTrailingSpace, true, this.tribute.allowSpaces, this.tribute.autocompleteMode); + + if (info) { + this.tribute.current.selectedPath = info.mentionSelectedPath; + this.tribute.current.mentionText = info.mentionText; + this.tribute.current.selectedOffset = info.mentionSelectedOffset; + } + } + }, { + key: "callbacks", + value: function callbacks() { + var _this = this; + + return { + triggerChar: function triggerChar(e, el, trigger) { + var tribute = _this.tribute; + tribute.current.trigger = trigger; + var collectionItem = tribute.collection.find(function (item) { + return item.trigger === trigger; + }); + tribute.current.collection = collectionItem; + + if (tribute.current.mentionText.length >= tribute.current.collection.menuShowMinLength && tribute.inputEvent) { + tribute.showMenuFor(el, true); + } + }, + enter: function enter(e, el) { + // choose selection + if (_this.tribute.isActive && _this.tribute.current.filteredItems) { + e.preventDefault(); + e.stopPropagation(); + setTimeout(function () { + _this.tribute.selectItemAtIndex(_this.tribute.menuSelected, e); + + _this.tribute.hideMenu(); + }, 0); + } + }, + escape: function escape(e, el) { + if (_this.tribute.isActive) { + e.preventDefault(); + e.stopPropagation(); + _this.tribute.isActive = false; + + _this.tribute.hideMenu(); + } + }, + tab: function tab(e, el) { + // choose first match + _this.callbacks().enter(e, el); + }, + space: function space(e, el) { + if (_this.tribute.isActive) { + if (_this.tribute.spaceSelectsMatch) { + _this.callbacks().enter(e, el); + } else if (!_this.tribute.allowSpaces) { + e.stopPropagation(); + setTimeout(function () { + _this.tribute.hideMenu(); + + _this.tribute.isActive = false; + }, 0); + } + } + }, + up: function up(e, el) { + // navigate up ul + if (_this.tribute.isActive && _this.tribute.current.filteredItems) { + e.preventDefault(); + e.stopPropagation(); + var count = _this.tribute.current.filteredItems.length, + selected = _this.tribute.menuSelected; + + if (count > selected && selected > 0) { + _this.tribute.menuSelected--; + + _this.setActiveLi(); + } else if (selected === 0) { + _this.tribute.menuSelected = count - 1; + + _this.setActiveLi(); + + _this.tribute.menu.scrollTop = _this.tribute.menu.scrollHeight; + } + } + }, + down: function down(e, el) { + // navigate down ul + if (_this.tribute.isActive && _this.tribute.current.filteredItems) { + e.preventDefault(); + e.stopPropagation(); + var count = _this.tribute.current.filteredItems.length - 1, + selected = _this.tribute.menuSelected; + + if (count > selected) { + _this.tribute.menuSelected++; + + _this.setActiveLi(); + } else if (count === selected) { + _this.tribute.menuSelected = 0; + + _this.setActiveLi(); + + _this.tribute.menu.scrollTop = 0; + } + } + }, + "delete": function _delete(e, el) { + if (_this.tribute.isActive && _this.tribute.current.mentionText.length < 1) { + _this.tribute.hideMenu(); + } else if (_this.tribute.isActive) { + _this.tribute.showMenuFor(el); + } + } + }; + } + }, { + key: "setActiveLi", + value: function setActiveLi(index) { + var lis = this.tribute.menu.querySelectorAll("li"), + length = lis.length >>> 0; + if (index) this.tribute.menuSelected = parseInt(index); + + for (var i = 0; i < length; i++) { + var li = lis[i]; + + if (i === this.tribute.menuSelected) { + li.classList.add(this.tribute.current.collection.selectClass); + var liClientRect = li.getBoundingClientRect(); + var menuClientRect = this.tribute.menu.getBoundingClientRect(); + + if (liClientRect.bottom > menuClientRect.bottom) { + var scrollDistance = liClientRect.bottom - menuClientRect.bottom; + this.tribute.menu.scrollTop += scrollDistance; + } else if (liClientRect.top < menuClientRect.top) { + var _scrollDistance = menuClientRect.top - liClientRect.top; + + this.tribute.menu.scrollTop -= _scrollDistance; + } + } else { + li.classList.remove(this.tribute.current.collection.selectClass); + } + } + } + }, { + key: "getFullHeight", + value: function getFullHeight(elem, includeMargin) { + var height = elem.getBoundingClientRect().height; + + if (includeMargin) { + var style = elem.currentStyle || window.getComputedStyle(elem); + return height + parseFloat(style.marginTop) + parseFloat(style.marginBottom); + } + + return height; + } + }], [{ + key: "keys", + value: function keys() { + return [{ + key: 9, + value: "TAB" + }, { + key: 8, + value: "DELETE" + }, { + key: 13, + value: "ENTER" + }, { + key: 27, + value: "ESCAPE" + }, { + key: 32, + value: "SPACE" + }, { + key: 38, + value: "UP" + }, { + key: 40, + value: "DOWN" + }]; + } + }]); + + return TributeEvents; + }(); + + var TributeMenuEvents = /*#__PURE__*/function () { + function TributeMenuEvents(tribute) { + _classCallCheck(this, TributeMenuEvents); + + this.tribute = tribute; + this.tribute.menuEvents = this; + this.menu = this.tribute.menu; + } + + _createClass(TributeMenuEvents, [{ + key: "bind", + value: function bind(menu) { + var _this = this; + + this.menuClickEvent = this.tribute.events.click.bind(null, this); + this.menuContainerScrollEvent = this.debounce(function () { + if (_this.tribute.isActive) { + _this.tribute.hideMenu(); + } + }, 10, false); + this.windowResizeEvent = this.debounce(function () { + if (_this.tribute.isActive) { + _this.tribute.hideMenu(); + } + }, 10, false); // fixes IE11 issues with mousedown + + this.tribute.range.getDocument().addEventListener("MSPointerDown", this.menuClickEvent, false); + this.tribute.range.getDocument().addEventListener("mousedown", this.menuClickEvent, false); + window.addEventListener("resize", this.windowResizeEvent); + + if (this.menuContainer) { + this.menuContainer.addEventListener("scroll", this.menuContainerScrollEvent, false); + } else { + window.addEventListener("scroll", this.menuContainerScrollEvent); + } + } + }, { + key: "unbind", + value: function unbind(menu) { + this.tribute.range.getDocument().removeEventListener("mousedown", this.menuClickEvent, false); + this.tribute.range.getDocument().removeEventListener("MSPointerDown", this.menuClickEvent, false); + window.removeEventListener("resize", this.windowResizeEvent); + + if (this.menuContainer) { + this.menuContainer.removeEventListener("scroll", this.menuContainerScrollEvent, false); + } else { + window.removeEventListener("scroll", this.menuContainerScrollEvent); + } + } + }, { + key: "debounce", + value: function debounce(func, wait, immediate) { + var _arguments = arguments, + _this2 = this; + + var timeout; + return function () { + var context = _this2, + args = _arguments; + + var later = function later() { + timeout = null; + if (!immediate) func.apply(context, args); + }; + + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) func.apply(context, args); + }; + } + }]); + + return TributeMenuEvents; + }(); + + var TributeRange = /*#__PURE__*/function () { + function TributeRange(tribute) { + _classCallCheck(this, TributeRange); + + this.tribute = tribute; + this.tribute.range = this; + } + + _createClass(TributeRange, [{ + key: "getDocument", + value: function getDocument() { + var iframe; + + if (this.tribute.current.collection) { + iframe = this.tribute.current.collection.iframe; + } + + if (!iframe) { + return document; + } + + return iframe.contentWindow.document; + } + }, { + key: "positionMenuAtCaret", + value: function positionMenuAtCaret(scrollTo) { + var context = this.tribute.current, + coordinates; + var info = this.getTriggerInfo(false, this.tribute.hasTrailingSpace, true, this.tribute.allowSpaces, this.tribute.autocompleteMode); + + if (typeof info !== 'undefined') { + if (!this.tribute.positionMenu) { + this.tribute.menu.style.cssText = "display: block;"; + return; + } + + if (!this.isContentEditable(context.element)) { + coordinates = this.getTextAreaOrInputUnderlinePosition(this.tribute.current.element, info.mentionPosition); + } else { + coordinates = this.getContentEditableCaretPosition(info.mentionPosition); + } + + this.tribute.menu.style.cssText = "top: ".concat(coordinates.top, "px;\n left: ").concat(coordinates.left, "px;\n right: ").concat(coordinates.right, "px;\n bottom: ").concat(coordinates.bottom, "px;\n max-height: ").concat(coordinates.maxHeight || 500, "px;\n max-width: ").concat(coordinates.maxWidth || 300, "px;\n position: ").concat(coordinates.position || 'absolute', ";\n display: block;"); + + if (coordinates.left === 'auto') { + this.tribute.menu.style.left = 'auto'; + } + + if (coordinates.top === 'auto') { + this.tribute.menu.style.top = 'auto'; + } + + if (scrollTo) this.scrollIntoView(); + } else { + this.tribute.menu.style.cssText = 'display: none'; + } + } + }, { + key: "selectElement", + value: function selectElement(targetElement, path, offset) { + var range; + var elem = targetElement; + + if (path) { + for (var i = 0; i < path.length; i++) { + elem = elem.childNodes[path[i]]; + + if (elem === undefined) { + return; + } + + while (elem.length < offset) { + offset -= elem.length; + elem = elem.nextSibling; + } + + if (elem.childNodes.length === 0 && !elem.length) { + elem = elem.previousSibling; + } + } + } + + var sel = this.getWindowSelection(); + range = this.getDocument().createRange(); + range.setStart(elem, offset); + range.setEnd(elem, offset); + range.collapse(true); + + try { + sel.removeAllRanges(); + } catch (error) {} + + sel.addRange(range); + targetElement.focus(); + } + }, { + key: "replaceTriggerText", + value: function replaceTriggerText(text, requireLeadingSpace, hasTrailingSpace, originalEvent, item) { + var info = this.getTriggerInfo(true, hasTrailingSpace, requireLeadingSpace, this.tribute.allowSpaces, this.tribute.autocompleteMode); + + if (info !== undefined) { + var context = this.tribute.current; + var replaceEvent = new CustomEvent('tribute-replaced', { + detail: { + item: item, + instance: context, + context: info, + event: originalEvent + } + }); + + if (!this.isContentEditable(context.element)) { + var myField = this.tribute.current.element; + var textSuffix = typeof this.tribute.replaceTextSuffix == 'string' ? this.tribute.replaceTextSuffix : ' '; + text += textSuffix; + var startPos = info.mentionPosition; + var endPos = info.mentionPosition + info.mentionText.length + textSuffix.length; + + if (!this.tribute.autocompleteMode) { + endPos += info.mentionTriggerChar.length - 1; + } + + myField.value = myField.value.substring(0, startPos) + text + myField.value.substring(endPos, myField.value.length); + myField.selectionStart = startPos + text.length; + myField.selectionEnd = startPos + text.length; + } else { + // add a space to the end of the pasted text + var _textSuffix = typeof this.tribute.replaceTextSuffix == 'string' ? this.tribute.replaceTextSuffix : '\xA0'; + + text += _textSuffix; + + var _endPos = info.mentionPosition + info.mentionText.length; + + if (!this.tribute.autocompleteMode) { + _endPos += info.mentionTriggerChar.length; + } + + this.pasteHtml(text, info.mentionPosition, _endPos); + } + + context.element.dispatchEvent(new CustomEvent('input', { + bubbles: true + })); + context.element.dispatchEvent(replaceEvent); + } + } + }, { + key: "pasteHtml", + value: function pasteHtml(html, startPos, endPos) { + var range, sel; + sel = this.getWindowSelection(); + range = this.getDocument().createRange(); + range.setStart(sel.anchorNode, startPos); + range.setEnd(sel.anchorNode, endPos); + range.deleteContents(); + var el = this.getDocument().createElement('div'); + el.innerHTML = html; + var frag = this.getDocument().createDocumentFragment(), + node, + lastNode; + + while (node = el.firstChild) { + lastNode = frag.appendChild(node); + } + + range.insertNode(frag); // Preserve the selection + + if (lastNode) { + range = range.cloneRange(); + range.setStartAfter(lastNode); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + } + } + }, { + key: "getWindowSelection", + value: function getWindowSelection() { + if (this.tribute.collection.iframe) { + return this.tribute.collection.iframe.contentWindow.getSelection(); + } + + return window.getSelection(); + } + }, { + key: "getNodePositionInParent", + value: function getNodePositionInParent(element) { + if (element.parentNode === null) { + return 0; + } + + for (var i = 0; i < element.parentNode.childNodes.length; i++) { + var node = element.parentNode.childNodes[i]; + + if (node === element) { + return i; + } + } + } + }, { + key: "getContentEditableSelectedPath", + value: function getContentEditableSelectedPath(ctx) { + var sel = this.getWindowSelection(); + var selected = sel.anchorNode; + var path = []; + var offset; + + if (selected != null) { + var i; + var ce = selected.contentEditable; + + while (selected !== null && ce !== 'true') { + i = this.getNodePositionInParent(selected); + path.push(i); + selected = selected.parentNode; + + if (selected !== null) { + ce = selected.contentEditable; + } + } + + path.reverse(); // getRangeAt may not exist, need alternative + + offset = sel.getRangeAt(0).startOffset; + return { + selected: selected, + path: path, + offset: offset + }; + } + } + }, { + key: "getTextPrecedingCurrentSelection", + value: function getTextPrecedingCurrentSelection() { + var context = this.tribute.current, + text = ''; + + if (!this.isContentEditable(context.element)) { + var textComponent = this.tribute.current.element; + + if (textComponent) { + var startPos = textComponent.selectionStart; + + if (textComponent.value && startPos >= 0) { + text = textComponent.value.substring(0, startPos); + } + } + } else { + var selectedElem = this.getWindowSelection().anchorNode; + + if (selectedElem != null) { + var workingNodeContent = selectedElem.textContent; + var selectStartOffset = this.getWindowSelection().getRangeAt(0).startOffset; + + if (workingNodeContent && selectStartOffset >= 0) { + text = workingNodeContent.substring(0, selectStartOffset); + } + } + } + + return text; + } + }, { + key: "getLastWordInText", + value: function getLastWordInText(text) { + text = text.replace(/\u00A0/g, ' '); // https://stackoverflow.com/questions/29850407/how-do-i-replace-unicode-character-u00a0-with-a-space-in-javascript + + var wordsArray; + + if (this.tribute.autocompleteSeparator) { + wordsArray = text.split(this.tribute.autocompleteSeparator); + } else { + wordsArray = text.split(/\s+/); + } + + var worldsCount = wordsArray.length - 1; + return wordsArray[worldsCount].trim(); + } + }, { + key: "getTriggerInfo", + value: function getTriggerInfo(menuAlreadyActive, hasTrailingSpace, requireLeadingSpace, allowSpaces, isAutocomplete) { + var _this = this; + + var ctx = this.tribute.current; + var selected, path, offset; + + if (!this.isContentEditable(ctx.element)) { + selected = this.tribute.current.element; + } else { + var selectionInfo = this.getContentEditableSelectedPath(ctx); + + if (selectionInfo) { + selected = selectionInfo.selected; + path = selectionInfo.path; + offset = selectionInfo.offset; + } + } + + var effectiveRange = this.getTextPrecedingCurrentSelection(); + var lastWordOfEffectiveRange = this.getLastWordInText(effectiveRange); + + if (isAutocomplete) { + return { + mentionPosition: effectiveRange.length - lastWordOfEffectiveRange.length, + mentionText: lastWordOfEffectiveRange, + mentionSelectedElement: selected, + mentionSelectedPath: path, + mentionSelectedOffset: offset + }; + } + + if (effectiveRange !== undefined && effectiveRange !== null) { + var mostRecentTriggerCharPos = -1; + var triggerChar; + this.tribute.collection.forEach(function (config) { + var c = config.trigger; + var idx = config.requireLeadingSpace ? _this.lastIndexWithLeadingSpace(effectiveRange, c) : effectiveRange.lastIndexOf(c); + + if (idx > mostRecentTriggerCharPos) { + mostRecentTriggerCharPos = idx; + triggerChar = c; + requireLeadingSpace = config.requireLeadingSpace; + } + }); + + if (mostRecentTriggerCharPos >= 0 && (mostRecentTriggerCharPos === 0 || !requireLeadingSpace || /[\xA0\s]/g.test(effectiveRange.substring(mostRecentTriggerCharPos - 1, mostRecentTriggerCharPos)))) { + var currentTriggerSnippet = effectiveRange.substring(mostRecentTriggerCharPos + triggerChar.length, effectiveRange.length); + triggerChar = effectiveRange.substring(mostRecentTriggerCharPos, mostRecentTriggerCharPos + triggerChar.length); + var firstSnippetChar = currentTriggerSnippet.substring(0, 1); + var leadingSpace = currentTriggerSnippet.length > 0 && (firstSnippetChar === ' ' || firstSnippetChar === '\xA0'); + + if (hasTrailingSpace) { + currentTriggerSnippet = currentTriggerSnippet.trim(); + } + + var regex = allowSpaces ? /[^\S ]/g : /[\xA0\s]/g; + this.tribute.hasTrailingSpace = regex.test(currentTriggerSnippet); + + if (!leadingSpace && (menuAlreadyActive || !regex.test(currentTriggerSnippet))) { + return { + mentionPosition: mostRecentTriggerCharPos, + mentionText: currentTriggerSnippet, + mentionSelectedElement: selected, + mentionSelectedPath: path, + mentionSelectedOffset: offset, + mentionTriggerChar: triggerChar + }; + } + } + } + } + }, { + key: "lastIndexWithLeadingSpace", + value: function lastIndexWithLeadingSpace(str, trigger) { + var reversedStr = str.split('').reverse().join(''); + var index = -1; + + for (var cidx = 0, len = str.length; cidx < len; cidx++) { + var firstChar = cidx === str.length - 1; + var leadingSpace = /\s/.test(reversedStr[cidx + 1]); + var match = true; + + for (var triggerIdx = trigger.length - 1; triggerIdx >= 0; triggerIdx--) { + if (trigger[triggerIdx] !== reversedStr[cidx - triggerIdx]) { + match = false; + break; + } + } + + if (match && (firstChar || leadingSpace)) { + index = str.length - 1 - cidx; + break; + } + } + + return index; + } + }, { + key: "isContentEditable", + value: function isContentEditable(element) { + return element.nodeName !== 'INPUT' && element.nodeName !== 'TEXTAREA'; + } + }, { + key: "isMenuOffScreen", + value: function isMenuOffScreen(coordinates, menuDimensions) { + var windowWidth = window.innerWidth; + var windowHeight = window.innerHeight; + var doc = document.documentElement; + var windowLeft = (window.pageXOffset || doc.scrollLeft) - (doc.clientLeft || 0); + var windowTop = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0); + var menuTop = typeof coordinates.top === 'number' ? coordinates.top : windowTop + windowHeight - coordinates.bottom - menuDimensions.height; + var menuRight = typeof coordinates.right === 'number' ? coordinates.right : coordinates.left + menuDimensions.width; + var menuBottom = typeof coordinates.bottom === 'number' ? coordinates.bottom : coordinates.top + menuDimensions.height; + var menuLeft = typeof coordinates.left === 'number' ? coordinates.left : windowLeft + windowWidth - coordinates.right - menuDimensions.width; + return { + top: menuTop < Math.floor(windowTop), + right: menuRight > Math.ceil(windowLeft + windowWidth), + bottom: menuBottom > Math.ceil(windowTop + windowHeight), + left: menuLeft < Math.floor(windowLeft) + }; + } + }, { + key: "getMenuDimensions", + value: function getMenuDimensions() { + // Width of the menu depends of its contents and position + // We must check what its width would be without any obstruction + // This way, we can achieve good positioning for flipping the menu + var dimensions = { + width: null, + height: null + }; + this.tribute.menu.style.cssText = "top: 0px;\n left: 0px;\n position: fixed;\n display: block;\n visibility; hidden;\n max-height:500px;"; + dimensions.width = this.tribute.menu.offsetWidth; + dimensions.height = this.tribute.menu.offsetHeight; + this.tribute.menu.style.cssText = "display: none;"; + return dimensions; + } + }, { + key: "getTextAreaOrInputUnderlinePosition", + value: function getTextAreaOrInputUnderlinePosition(element, position, flipped) { + var properties = ['direction', 'boxSizing', 'width', 'height', 'overflowX', 'overflowY', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStyle', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'fontSizeAdjust', 'lineHeight', 'fontFamily', 'textAlign', 'textTransform', 'textIndent', 'textDecoration', 'letterSpacing', 'wordSpacing']; + var div = this.getDocument().createElement('div'); + div.id = 'input-textarea-caret-position-mirror-div'; + this.getDocument().body.appendChild(div); + var style = div.style; + var computed = window.getComputedStyle ? getComputedStyle(element) : element.currentStyle; + style.whiteSpace = 'pre-wrap'; + + if (element.nodeName !== 'INPUT') { + style.wordWrap = 'break-word'; + } + + style.position = 'absolute'; + style.visibility = 'hidden'; // transfer the element's properties to the div + + properties.forEach(function (prop) { + style[prop] = computed[prop]; + }); //NOT SURE WHY THIS IS HERE AND IT DOESNT SEEM HELPFUL + // if (isFirefox) { + // style.width = `${(parseInt(computed.width) - 2)}px` + // if (element.scrollHeight > parseInt(computed.height)) + // style.overflowY = 'scroll' + // } else { + // style.overflow = 'hidden' + // } + + var span0 = document.createElement('span'); + span0.textContent = element.value.substring(0, position); + div.appendChild(span0); + + if (element.nodeName === 'INPUT') { + div.textContent = div.textContent.replace(/\s/g, ' '); + } //Create a span in the div that represents where the cursor + //should be + + + var span = this.getDocument().createElement('span'); //we give it no content as this represents the cursor + + span.textContent = '​'; + div.appendChild(span); + var span2 = this.getDocument().createElement('span'); + span2.textContent = element.value.substring(position); + div.appendChild(span2); + var rect = element.getBoundingClientRect(); //position the div exactly over the element + //so we can get the bounding client rect for the span and + //it should represent exactly where the cursor is + + div.style.position = 'fixed'; + div.style.left = rect.left + 'px'; + div.style.top = rect.top + 'px'; + div.style.width = rect.width + 'px'; + div.style.height = rect.height + 'px'; + div.scrollTop = element.scrollTop; + var spanRect = span.getBoundingClientRect(); + this.getDocument().body.removeChild(div); + return this.getFixedCoordinatesRelativeToRect(spanRect); + } + }, { + key: "getContentEditableCaretPosition", + value: function getContentEditableCaretPosition(selectedNodePosition) { + var range; + var sel = this.getWindowSelection(); + range = this.getDocument().createRange(); + range.setStart(sel.anchorNode, selectedNodePosition); + range.setEnd(sel.anchorNode, selectedNodePosition); + range.collapse(false); + var rect = range.getBoundingClientRect(); + return this.getFixedCoordinatesRelativeToRect(rect); + } + }, { + key: "getFixedCoordinatesRelativeToRect", + value: function getFixedCoordinatesRelativeToRect(rect) { + var coordinates = { + position: 'fixed', + left: rect.left, + top: rect.top + rect.height + }; + var menuDimensions = this.getMenuDimensions(); + var availableSpaceOnTop = rect.top; + var availableSpaceOnBottom = window.innerHeight - (rect.top + rect.height); //check to see where's the right place to put the menu vertically + + if (availableSpaceOnBottom < menuDimensions.height) { + if (availableSpaceOnTop >= menuDimensions.height || availableSpaceOnTop > availableSpaceOnBottom) { + coordinates.top = 'auto'; + coordinates.bottom = window.innerHeight - rect.top; + + if (availableSpaceOnBottom < menuDimensions.height) { + coordinates.maxHeight = availableSpaceOnTop; + } + } else { + if (availableSpaceOnTop < menuDimensions.height) { + coordinates.maxHeight = availableSpaceOnBottom; + } + } + } + + var availableSpaceOnLeft = rect.left; + var availableSpaceOnRight = window.innerWidth - rect.left; //check to see where's the right place to put the menu horizontally + + if (availableSpaceOnRight < menuDimensions.width) { + if (availableSpaceOnLeft >= menuDimensions.width || availableSpaceOnLeft > availableSpaceOnRight) { + coordinates.left = 'auto'; + coordinates.right = window.innerWidth - rect.left; + + if (availableSpaceOnRight < menuDimensions.width) { + coordinates.maxWidth = availableSpaceOnLeft; + } + } else { + if (availableSpaceOnLeft < menuDimensions.width) { + coordinates.maxWidth = availableSpaceOnRight; + } + } + } + + return coordinates; + } + }, { + key: "scrollIntoView", + value: function scrollIntoView(elem) { + var reasonableBuffer = 20, + clientRect; + var maxScrollDisplacement = 100; + var e = this.menu; + if (typeof e === 'undefined') return; + + while (clientRect === undefined || clientRect.height === 0) { + clientRect = e.getBoundingClientRect(); + + if (clientRect.height === 0) { + e = e.childNodes[0]; + + if (e === undefined || !e.getBoundingClientRect) { + return; + } + } + } + + var elemTop = clientRect.top; + var elemBottom = elemTop + clientRect.height; + + if (elemTop < 0) { + window.scrollTo(0, window.pageYOffset + clientRect.top - reasonableBuffer); + } else if (elemBottom > window.innerHeight) { + var maxY = window.pageYOffset + clientRect.top - reasonableBuffer; + + if (maxY - window.pageYOffset > maxScrollDisplacement) { + maxY = window.pageYOffset + maxScrollDisplacement; + } + + var targetY = window.pageYOffset - (window.innerHeight - elemBottom); + + if (targetY > maxY) { + targetY = maxY; + } + + window.scrollTo(0, targetY); + } + } + }, { + key: "menuContainerIsBody", + get: function get() { + return this.tribute.menuContainer === document.body || !this.tribute.menuContainer; + } + }]); + + return TributeRange; + }(); + + // Thanks to https://github.com/mattyork/fuzzy + var TributeSearch = /*#__PURE__*/function () { + function TributeSearch(tribute) { + _classCallCheck(this, TributeSearch); + + this.tribute = tribute; + this.tribute.search = this; + } + + _createClass(TributeSearch, [{ + key: "simpleFilter", + value: function simpleFilter(pattern, array) { + var _this = this; + + return array.filter(function (string) { + return _this.test(pattern, string); + }); + } + }, { + key: "test", + value: function test(pattern, string) { + return this.match(pattern, string) !== null; + } + }, { + key: "match", + value: function match(pattern, string, opts) { + opts = opts || {}; + var len = string.length, + pre = opts.pre || '', + post = opts.post || '', + compareString = opts.caseSensitive && string || string.toLowerCase(); + + if (opts.skip) { + return { + rendered: string, + score: 0 + }; + } + + pattern = opts.caseSensitive && pattern || pattern.toLowerCase(); + var patternCache = this.traverse(compareString, pattern, 0, 0, []); + + if (!patternCache) { + return null; + } + + return { + rendered: this.render(string, patternCache.cache, pre, post), + score: patternCache.score + }; + } + }, { + key: "traverse", + value: function traverse(string, pattern, stringIndex, patternIndex, patternCache) { + if (this.tribute.autocompleteSeparator) { + // if the pattern search at end + pattern = pattern.split(this.tribute.autocompleteSeparator).splice(-1)[0]; + } + + if (pattern.length === patternIndex) { + // calculate score and copy the cache containing the indices where it's found + return { + score: this.calculateScore(patternCache), + cache: patternCache.slice() + }; + } // if string at end or remaining pattern > remaining string + + + if (string.length === stringIndex || pattern.length - patternIndex > string.length - stringIndex) { + return undefined; + } + + var c = pattern[patternIndex]; + var index = string.indexOf(c, stringIndex); + var best, temp; + + while (index > -1) { + patternCache.push(index); + temp = this.traverse(string, pattern, index + 1, patternIndex + 1, patternCache); + patternCache.pop(); // if downstream traversal failed, return best answer so far + + if (!temp) { + return best; + } + + if (!best || best.score < temp.score) { + best = temp; + } + + index = string.indexOf(c, index + 1); + } + + return best; + } + }, { + key: "calculateScore", + value: function calculateScore(patternCache) { + var score = 0; + var temp = 1; + patternCache.forEach(function (index, i) { + if (i > 0) { + if (patternCache[i - 1] + 1 === index) { + temp += temp + 1; + } else { + temp = 1; + } + } + + score += temp; + }); + return score; + } + }, { + key: "render", + value: function render(string, indices, pre, post) { + var rendered = string.substring(0, indices[0]); + indices.forEach(function (index, i) { + rendered += pre + string[index] + post + string.substring(index + 1, indices[i + 1] ? indices[i + 1] : string.length); + }); + return rendered; + } + }, { + key: "filter", + value: function filter(pattern, arr, opts) { + var _this2 = this; + + opts = opts || {}; + return arr.reduce(function (prev, element, idx, arr) { + var str = element; + + if (opts.extract) { + str = opts.extract(element); + + if (!str) { + // take care of undefineds / nulls / etc. + str = ''; + } + } + + var rendered = _this2.match(pattern, str, opts); + + if (rendered != null) { + prev[prev.length] = { + string: rendered.rendered, + score: rendered.score, + index: idx, + original: element + }; + } + + return prev; + }, []).sort(function (a, b) { + var compare = b.score - a.score; + if (compare) return compare; + return a.index - b.index; + }); + } + }]); + + return TributeSearch; + }(); + + var Tribute = /*#__PURE__*/function () { + function Tribute(_ref) { + var _this = this; + + var _ref$values = _ref.values, + values = _ref$values === void 0 ? null : _ref$values, + _ref$loadingItemTempl = _ref.loadingItemTemplate, + loadingItemTemplate = _ref$loadingItemTempl === void 0 ? null : _ref$loadingItemTempl, + _ref$iframe = _ref.iframe, + iframe = _ref$iframe === void 0 ? null : _ref$iframe, + _ref$selectClass = _ref.selectClass, + selectClass = _ref$selectClass === void 0 ? "highlight" : _ref$selectClass, + _ref$containerClass = _ref.containerClass, + containerClass = _ref$containerClass === void 0 ? "tribute-container" : _ref$containerClass, + _ref$itemClass = _ref.itemClass, + itemClass = _ref$itemClass === void 0 ? "" : _ref$itemClass, + _ref$trigger = _ref.trigger, + trigger = _ref$trigger === void 0 ? "@" : _ref$trigger, + _ref$autocompleteMode = _ref.autocompleteMode, + autocompleteMode = _ref$autocompleteMode === void 0 ? false : _ref$autocompleteMode, + _ref$autocompleteSepa = _ref.autocompleteSeparator, + autocompleteSeparator = _ref$autocompleteSepa === void 0 ? null : _ref$autocompleteSepa, + _ref$selectTemplate = _ref.selectTemplate, + selectTemplate = _ref$selectTemplate === void 0 ? null : _ref$selectTemplate, + _ref$menuItemTemplate = _ref.menuItemTemplate, + menuItemTemplate = _ref$menuItemTemplate === void 0 ? null : _ref$menuItemTemplate, + _ref$lookup = _ref.lookup, + lookup = _ref$lookup === void 0 ? "key" : _ref$lookup, + _ref$fillAttr = _ref.fillAttr, + fillAttr = _ref$fillAttr === void 0 ? "value" : _ref$fillAttr, + _ref$collection = _ref.collection, + collection = _ref$collection === void 0 ? null : _ref$collection, + _ref$menuContainer = _ref.menuContainer, + menuContainer = _ref$menuContainer === void 0 ? null : _ref$menuContainer, + _ref$noMatchTemplate = _ref.noMatchTemplate, + noMatchTemplate = _ref$noMatchTemplate === void 0 ? null : _ref$noMatchTemplate, + _ref$requireLeadingSp = _ref.requireLeadingSpace, + requireLeadingSpace = _ref$requireLeadingSp === void 0 ? true : _ref$requireLeadingSp, + _ref$allowSpaces = _ref.allowSpaces, + allowSpaces = _ref$allowSpaces === void 0 ? false : _ref$allowSpaces, + _ref$replaceTextSuffi = _ref.replaceTextSuffix, + replaceTextSuffix = _ref$replaceTextSuffi === void 0 ? null : _ref$replaceTextSuffi, + _ref$positionMenu = _ref.positionMenu, + positionMenu = _ref$positionMenu === void 0 ? true : _ref$positionMenu, + _ref$spaceSelectsMatc = _ref.spaceSelectsMatch, + spaceSelectsMatch = _ref$spaceSelectsMatc === void 0 ? false : _ref$spaceSelectsMatc, + _ref$searchOpts = _ref.searchOpts, + searchOpts = _ref$searchOpts === void 0 ? {} : _ref$searchOpts, + _ref$menuItemLimit = _ref.menuItemLimit, + menuItemLimit = _ref$menuItemLimit === void 0 ? null : _ref$menuItemLimit, + _ref$menuShowMinLengt = _ref.menuShowMinLength, + menuShowMinLength = _ref$menuShowMinLengt === void 0 ? 0 : _ref$menuShowMinLengt; + + _classCallCheck(this, Tribute); + + this.autocompleteMode = autocompleteMode; + this.autocompleteSeparator = autocompleteSeparator; + this.menuSelected = 0; + this.current = {}; + this.inputEvent = false; + this.isActive = false; + this.menuContainer = menuContainer; + this.allowSpaces = allowSpaces; + this.replaceTextSuffix = replaceTextSuffix; + this.positionMenu = positionMenu; + this.hasTrailingSpace = false; + this.spaceSelectsMatch = spaceSelectsMatch; + + if (this.autocompleteMode) { + trigger = ""; + allowSpaces = false; + } + + if (values) { + this.collection = [{ + // symbol that starts the lookup + trigger: trigger, + // is it wrapped in an iframe + iframe: iframe, + // class applied to selected item + selectClass: selectClass, + // class applied to the Container + containerClass: containerClass, + // class applied to each item + itemClass: itemClass, + // function called on select that retuns the content to insert + selectTemplate: (selectTemplate || Tribute.defaultSelectTemplate).bind(this), + // function called that returns content for an item + menuItemTemplate: (menuItemTemplate || Tribute.defaultMenuItemTemplate).bind(this), + // function called when menu is empty, disables hiding of menu. + noMatchTemplate: function (t) { + if (typeof t === "string") { + if (t.trim() === "") return null; + return t; + } + + if (typeof t === "function") { + return t.bind(_this); + } + + return noMatchTemplate || function () { + return "
  • No Match Found!
  • "; + }.bind(_this); + }(noMatchTemplate), + // column to search against in the object + lookup: lookup, + // column that contains the content to insert by default + fillAttr: fillAttr, + // array of objects or a function returning an array of objects + values: values, + // useful for when values is an async function + loadingItemTemplate: loadingItemTemplate, + requireLeadingSpace: requireLeadingSpace, + searchOpts: searchOpts, + menuItemLimit: menuItemLimit, + menuShowMinLength: menuShowMinLength + }]; + } else if (collection) { + if (this.autocompleteMode) console.warn("Tribute in autocomplete mode does not work for collections"); + this.collection = collection.map(function (item) { + return { + trigger: item.trigger || trigger, + iframe: item.iframe || iframe, + selectClass: item.selectClass || selectClass, + containerClass: item.containerClass || containerClass, + itemClass: item.itemClass || itemClass, + selectTemplate: (item.selectTemplate || Tribute.defaultSelectTemplate).bind(_this), + menuItemTemplate: (item.menuItemTemplate || Tribute.defaultMenuItemTemplate).bind(_this), + // function called when menu is empty, disables hiding of menu. + noMatchTemplate: function (t) { + if (typeof t === "string") { + if (t.trim() === "") return null; + return t; + } + + if (typeof t === "function") { + return t.bind(_this); + } + + return noMatchTemplate || function () { + return "
  • No Match Found!
  • "; + }.bind(_this); + }(noMatchTemplate), + lookup: item.lookup || lookup, + fillAttr: item.fillAttr || fillAttr, + values: item.values, + loadingItemTemplate: item.loadingItemTemplate, + requireLeadingSpace: item.requireLeadingSpace, + searchOpts: item.searchOpts || searchOpts, + menuItemLimit: item.menuItemLimit || menuItemLimit, + menuShowMinLength: item.menuShowMinLength || menuShowMinLength + }; + }); + } else { + throw new Error("[Tribute] No collection specified."); + } + + new TributeRange(this); + new TributeEvents(this); + new TributeMenuEvents(this); + new TributeSearch(this); + } + + _createClass(Tribute, [{ + key: "triggers", + value: function triggers() { + return this.collection.map(function (config) { + return config.trigger; + }); + } + }, { + key: "attach", + value: function attach(el) { + if (!el) { + throw new Error("[Tribute] Must pass in a DOM node or NodeList."); + } // Check if it is a jQuery collection + + + if (typeof jQuery !== "undefined" && el instanceof jQuery) { + el = el.get(); + } // Is el an Array/Array-like object? + + + if (el.constructor === NodeList || el.constructor === HTMLCollection || el.constructor === Array) { + var length = el.length; + + for (var i = 0; i < length; ++i) { + this._attach(el[i]); + } + } else { + this._attach(el); + } + } + }, { + key: "_attach", + value: function _attach(el) { + if (el.hasAttribute("data-tribute")) { + console.warn("Tribute was already bound to " + el.nodeName); + } + + this.ensureEditable(el); + this.events.bind(el); + el.setAttribute("data-tribute", true); + } + }, { + key: "ensureEditable", + value: function ensureEditable(element) { + if (Tribute.inputTypes().indexOf(element.nodeName) === -1) { + if (element.contentEditable) { + element.contentEditable = true; + } else { + throw new Error("[Tribute] Cannot bind to " + element.nodeName); + } + } + } + }, { + key: "createMenu", + value: function createMenu(containerClass) { + var wrapper = this.range.getDocument().createElement("div"), + ul = this.range.getDocument().createElement("ul"); + wrapper.className = containerClass; + wrapper.appendChild(ul); + + if (this.menuContainer) { + return this.menuContainer.appendChild(wrapper); + } + + return this.range.getDocument().body.appendChild(wrapper); + } + }, { + key: "showMenuFor", + value: function showMenuFor(element, scrollTo) { + var _this2 = this; + + // Only proceed if menu isn't already shown for the current element & mentionText + if (this.isActive && this.current.element === element && this.current.mentionText === this.currentMentionTextSnapshot) { + return; + } + + this.currentMentionTextSnapshot = this.current.mentionText; // create the menu if it doesn't exist. + + if (!this.menu) { + this.menu = this.createMenu(this.current.collection.containerClass); + element.tributeMenu = this.menu; + this.menuEvents.bind(this.menu); + } + + this.isActive = true; + this.menuSelected = 0; + + if (!this.current.mentionText) { + this.current.mentionText = ""; + } + + var processValues = function processValues(values) { + // Tribute may not be active any more by the time the value callback returns + if (!_this2.isActive) { + return; + } + + var items = _this2.search.filter(_this2.current.mentionText, values, { + pre: _this2.current.collection.searchOpts.pre || "", + post: _this2.current.collection.searchOpts.post || "", + skip: _this2.current.collection.searchOpts.skip, + extract: function extract(el) { + if (typeof _this2.current.collection.lookup === "string") { + return el[_this2.current.collection.lookup]; + } else if (typeof _this2.current.collection.lookup === "function") { + return _this2.current.collection.lookup(el, _this2.current.mentionText); + } else { + throw new Error("Invalid lookup attribute, lookup must be string or function."); + } + } + }); + + if (_this2.current.collection.menuItemLimit) { + items = items.slice(0, _this2.current.collection.menuItemLimit); + } + + _this2.current.filteredItems = items; + + var ul = _this2.menu.querySelector("ul"); + + if (!items.length) { + var noMatchEvent = new CustomEvent("tribute-no-match", { + detail: _this2.menu + }); + + _this2.current.element.dispatchEvent(noMatchEvent); + + if (typeof _this2.current.collection.noMatchTemplate === "function" && !_this2.current.collection.noMatchTemplate() || !_this2.current.collection.noMatchTemplate) { + _this2.hideMenu(); + } else { + typeof _this2.current.collection.noMatchTemplate === "function" ? ul.innerHTML = _this2.current.collection.noMatchTemplate() : ul.innerHTML = _this2.current.collection.noMatchTemplate; + + _this2.range.positionMenuAtCaret(scrollTo); + } + + return; + } + + ul.innerHTML = ""; + + var fragment = _this2.range.getDocument().createDocumentFragment(); + + items.forEach(function (item, index) { + var li = _this2.range.getDocument().createElement("li"); + + li.setAttribute("data-index", index); + li.className = _this2.current.collection.itemClass; + li.addEventListener("mousemove", function (e) { + var _this2$_findLiTarget = _this2._findLiTarget(e.target), + _this2$_findLiTarget2 = _slicedToArray(_this2$_findLiTarget, 2), + li = _this2$_findLiTarget2[0], + index = _this2$_findLiTarget2[1]; + + if (e.movementY !== 0) { + _this2.events.setActiveLi(index); + } + }); + + if (_this2.menuSelected === index) { + li.classList.add(_this2.current.collection.selectClass); + } + + li.innerHTML = _this2.current.collection.menuItemTemplate(item); + fragment.appendChild(li); + }); + ul.appendChild(fragment); + + _this2.range.positionMenuAtCaret(scrollTo); + }; + + if (typeof this.current.collection.values === "function") { + if (this.current.collection.loadingItemTemplate) { + this.menu.querySelector("ul").innerHTML = this.current.collection.loadingItemTemplate; + this.range.positionMenuAtCaret(scrollTo); + } + + this.current.collection.values(this.current.mentionText, processValues); + } else { + processValues(this.current.collection.values); + } + } + }, { + key: "_findLiTarget", + value: function _findLiTarget(el) { + if (!el) return []; + var index = el.getAttribute("data-index"); + return !index ? this._findLiTarget(el.parentNode) : [el, index]; + } + }, { + key: "showMenuForCollection", + value: function showMenuForCollection(element, collectionIndex) { + if (element !== document.activeElement) { + this.placeCaretAtEnd(element); + } + + this.current.collection = this.collection[collectionIndex || 0]; + this.current.externalTrigger = true; + this.current.element = element; + if (element.isContentEditable) this.insertTextAtCursor(this.current.collection.trigger);else this.insertAtCaret(element, this.current.collection.trigger); + this.showMenuFor(element); + } // TODO: make sure this works for inputs/textareas + + }, { + key: "placeCaretAtEnd", + value: function placeCaretAtEnd(el) { + el.focus(); + + if (typeof window.getSelection != "undefined" && typeof document.createRange != "undefined") { + var range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + } else if (typeof document.body.createTextRange != "undefined") { + var textRange = document.body.createTextRange(); + textRange.moveToElementText(el); + textRange.collapse(false); + textRange.select(); + } + } // for contenteditable + + }, { + key: "insertTextAtCursor", + value: function insertTextAtCursor(text) { + var sel, range; + sel = window.getSelection(); + range = sel.getRangeAt(0); + range.deleteContents(); + var textNode = document.createTextNode(text); + range.insertNode(textNode); + range.selectNodeContents(textNode); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + } // for regular inputs + + }, { + key: "insertAtCaret", + value: function insertAtCaret(textarea, text) { + var scrollPos = textarea.scrollTop; + var caretPos = textarea.selectionStart; + var front = textarea.value.substring(0, caretPos); + var back = textarea.value.substring(textarea.selectionEnd, textarea.value.length); + textarea.value = front + text + back; + caretPos = caretPos + text.length; + textarea.selectionStart = caretPos; + textarea.selectionEnd = caretPos; + textarea.focus(); + textarea.scrollTop = scrollPos; + } + }, { + key: "hideMenu", + value: function hideMenu() { + if (this.menu) { + this.menu.style.cssText = "display: none;"; + this.isActive = false; + this.menuSelected = 0; + this.current = {}; + } + } + }, { + key: "selectItemAtIndex", + value: function selectItemAtIndex(index, originalEvent) { + index = parseInt(index); + if (typeof index !== "number" || isNaN(index)) return; + var item = this.current.filteredItems[index]; + var content = this.current.collection.selectTemplate(item); + if (content !== null) this.replaceText(content, originalEvent, item); + } + }, { + key: "replaceText", + value: function replaceText(content, originalEvent, item) { + this.range.replaceTriggerText(content, true, true, originalEvent, item); + } + }, { + key: "_append", + value: function _append(collection, newValues, replace) { + if (typeof collection.values === "function") { + throw new Error("Unable to append to values, as it is a function."); + } else if (!replace) { + collection.values = collection.values.concat(newValues); + } else { + collection.values = newValues; + } + } + }, { + key: "append", + value: function append(collectionIndex, newValues, replace) { + var index = parseInt(collectionIndex); + if (typeof index !== "number") throw new Error("please provide an index for the collection to update."); + var collection = this.collection[index]; + + this._append(collection, newValues, replace); + } + }, { + key: "appendCurrent", + value: function appendCurrent(newValues, replace) { + if (this.isActive) { + this._append(this.current.collection, newValues, replace); + } else { + throw new Error("No active state. Please use append instead and pass an index."); + } + } + }, { + key: "detach", + value: function detach(el) { + if (!el) { + throw new Error("[Tribute] Must pass in a DOM node or NodeList."); + } // Check if it is a jQuery collection + + + if (typeof jQuery !== "undefined" && el instanceof jQuery) { + el = el.get(); + } // Is el an Array/Array-like object? + + + if (el.constructor === NodeList || el.constructor === HTMLCollection || el.constructor === Array) { + var length = el.length; + + for (var i = 0; i < length; ++i) { + this._detach(el[i]); + } + } else { + this._detach(el); + } + } + }, { + key: "_detach", + value: function _detach(el) { + var _this3 = this; + + this.events.unbind(el); + + if (el.tributeMenu) { + this.menuEvents.unbind(el.tributeMenu); + } + + setTimeout(function () { + el.removeAttribute("data-tribute"); + _this3.isActive = false; + + if (el.tributeMenu) { + el.tributeMenu.remove(); + } + }); + } + }, { + key: "isActive", + get: function get() { + return this._isActive; + }, + set: function set(val) { + if (this._isActive != val) { + this._isActive = val; + + if (this.current.element) { + var noMatchEvent = new CustomEvent("tribute-active-".concat(val)); + this.current.element.dispatchEvent(noMatchEvent); + } + } + } + }], [{ + key: "defaultSelectTemplate", + value: function defaultSelectTemplate(item) { + if (typeof item === "undefined") return "".concat(this.current.collection.trigger).concat(this.current.mentionText); + + if (this.range.isContentEditable(this.current.element)) { + return '' + (this.current.collection.trigger + item.original[this.current.collection.fillAttr]) + ""; + } + + return this.current.collection.trigger + item.original[this.current.collection.fillAttr]; + } + }, { + key: "defaultMenuItemTemplate", + value: function defaultMenuItemTemplate(matchItem) { + return matchItem.string; + } + }, { + key: "inputTypes", + value: function inputTypes() { + return ["TEXTAREA", "INPUT"]; + } + }]); + + return Tribute; + }(); + + /** + * Tribute.js + * Native ES6 JavaScript @mention Plugin + **/ + + return Tribute; + +}))); diff --git a/pybb/static/js/tribute_mention.js b/pybb/static/js/tribute_mention.js new file mode 100644 index 00000000..9603c11e --- /dev/null +++ b/pybb/static/js/tribute_mention.js @@ -0,0 +1,104 @@ +/* Taken from https://github.com/zurb/tribute + + See subfolder tribute/ for license. + + Thanks a lot for this awesome library! + */ + +function remoteSearch(term, cb) { + $.ajax('/messages/django_messages_wl/get_usernames/', { + data: { term: term }, + dataType: 'json', + }).done(function (users) { + cb(users); + }) +}; + +var tribute = new Tribute({ + // symbol or string that starts the lookup + trigger: '@', + + // element to target for @mentions + iframe: null, + + // class added in the flyout menu for active item + selectClass: 'highlight', + + // class added to the menu container + containerClass: 'tribute-container', + + // class added to each list item + itemClass: '', + + // function called on select that returns the content to insert + selectTemplate: function (item) { + return '@' + item.original.value; + }, + + // template for displaying item in menu + menuItemTemplate: function (item) { + return item.string; + }, + + // template for when no match is found (optional), + // If no template is provided, menu is hidden. + noMatchTemplate: null, + + // specify an alternative parent container for the menu + // container must be a positioned element for the menu to appear correctly ie. `position: relative;` + // default container is the body + menuContainer: document.body, + + // column to search against in the object (accepts function or string) + lookup: 'value', + + // column that contains the content to insert by default + fillAttr: 'value', + + // REQUIRED: array of objects to match or a function that returns data (see 'Loading remote data' for an example) + values: function (text, cb) { + remoteSearch(text, users => cb(users)); + }, + + // When your values function is async, an optional loading template to show + loadingItemTemplate: null, + + // specify whether a space is required before the trigger string + requireLeadingSpace: true, + + // specify whether a space is allowed in the middle of mentions + allowSpaces: false, + + // optionally specify a custom suffix for the replace text + // (defaults to empty space if undefined) + replaceTextSuffix: ' ', + + // specify whether the menu should be positioned. Set to false and use in conjuction with menuContainer to create an inline menu + // (defaults to true) + positionMenu: true, + + // when the spacebar is hit, select the current match + spaceSelectsMatch: false, + + // turn tribute into an autocomplete + autocompleteMode: false, + + // Customize the elements used to wrap matched strings within the results list + // defaults to if undefined + searchOpts: { + pre: '', + post: '', + skip: false // true will skip local search, useful if doing server-side search + }, + + // Limits the number of items in the menu + menuItemLimit: 25, + + // specify the minimum number of characters that must be typed before menu appears + menuShowMinLength: 3 +}); + +$(document).ready(function() { + tribute.attach(document.getElementById("id_body")); +}); + diff --git a/pybb/templates/pybb/post_form.html b/pybb/templates/pybb/post_form.html index 6c3cdb4e..58fcba44 100644 --- a/pybb/templates/pybb/post_form.html +++ b/pybb/templates/pybb/post_form.html @@ -8,6 +8,9 @@ {% block extra_head %} + + + - + + + + + + + + @@ -44,6 +43,7 @@ }); {% endblock %} +{% endcomment %} {# HMTL included to parent templates, no block tag #}