Parse @-mentions in bugdown and style them.

* This makes bugdown.convert take a `message` parameter. Properties
    for parsed mentions are added to the message object by the `Pattern`
    for use in do_send_messages.

  * Refactor repeated markdown rendering code into `Message` model methods.

(imported from commit 4f0ed5570104c0210f984b6de21e9048e2b53fa0)
This commit is contained in:
Kevin Mehall 2013-06-28 10:02:58 -04:00
parent 7604534050
commit dce1f7f729
6 changed files with 148 additions and 72 deletions

View File

@ -192,42 +192,6 @@ def log_message(message):
if not message.sending_client.name.startswith("test:"):
log_event(message.to_log_dict())
# Match multi-word string between @** ** or match any one-word
# sequences after @
find_mentions_re = re.compile(r'\B@(?:\*\*([^\*]+)\*\*)|@(\w+)')
def mentioned_in_message(message):
# Determine what, if any, users are mentioned with an @-notification
# in this message
#
# TODO(leo) There is a minor regression in that we no longer
# match just-first-names or just-last-names
wildcards = ['all', 'everyone']
potential_mentions = find_mentions_re.findall(message.content)
# Either the first or the second group matched something, take the
# one that did (find_all returns a list with each item containing all groups)
potential_mentions = map(lambda elem: elem[0] or elem[1], potential_mentions)
users = set()
for mention in potential_mentions:
if mention in wildcards:
return (True, set())
attempts = [Q(full_name__iexact=mention), Q(short_name__iexact=mention)]
found = False
for attempt in attempts:
ups = UserProfile.objects.filter(attempt, realm=message.sender.realm)
for user in ups:
users.add(user.id)
found = len(ups) > 0
break
if found:
continue
return (False, users)
# Helper function. Defaults here are overriden by those set in do_send_messages
def do_send_message(message, rendered_content = None, no_log = False, stream = None):
do_send_messages([{'message': message,
@ -270,6 +234,8 @@ def do_send_messages(messages):
else:
raise ValueError('Bad recipient type')
message['message'].maybe_render_content()
# Save the message receipts in the database
user_message_flags = defaultdict(dict)
with transaction.commit_on_success():
@ -279,7 +245,12 @@ def do_send_messages(messages):
ums_to_create = [UserMessage(user_profile=user_profile, message=message['message'])
for user_profile in message['recipients']
if user_profile.is_active]
wildcard, mentioned_ids = mentioned_in_message(message['message'])
# These properties on the Message are set via
# Message.render_markdown by code in the bugdown inline patterns
wildcard = message['message'].mentions_wildcard
mentioned_ids = message['message'].mentions_user_ids
for um in ums_to_create:
sent_by_human = message['message'].sending_client.name.lower() in \
['website', 'iphone', 'android']
@ -298,7 +269,7 @@ def do_send_messages(messages):
# Render Markdown etc. here and store (automatically) in
# memcached, so that the single-threaded Tornado server
# doesn't have to.
message['message'].to_dict(apply_markdown=True, rendered_content=message['rendered_content'])
message['message'].to_dict(apply_markdown=True)
message['message'].to_dict(apply_markdown=False)
user_flags = user_message_flags.get(message['message'].id, {})
data = dict(
@ -453,15 +424,9 @@ def check_message(sender, client, message_type_name, message_to,
else:
return "Invalid message type"
rendered_content = bugdown.convert(message_content, sender.realm.domain)
if rendered_content is None:
return "We were unable to render your message"
message = Message()
message.sender = sender
message.content = message_content
message.rendered_content = rendered_content
message.rendered_content_version = bugdown.version
message.recipient = recipient
if message_type_name == 'stream':
message.subject = subject
@ -472,11 +437,13 @@ def check_message(sender, client, message_type_name, message_to,
message.pub_date = timezone.now()
message.sending_client = client
if not message.maybe_render_content():
return "We were unable to render your message"
if client.name == "zephyr_mirror" and already_sent_mirrored_message(message):
return {'message': None}
return {'message': message, 'rendered_content': rendered_content,
'stream': stream}
return {'message': message, 'stream': stream}
def internal_prep_message(sender_email, recipient_type_name, recipients,
subject, content, realm=None):
@ -967,8 +934,8 @@ def do_update_message(user_profile, message_id, subject, content):
if content is not None:
if len(content) > MAX_MESSAGE_LENGTH:
raise JsonableError("Message too long")
rendered_content = bugdown.convert(content, message.sender.realm.domain)
if rendered_content is None:
rendered_content = message.render_markdown(content)
if not rendered_content:
raise JsonableError("We were unable to render your updated message")
if not settings.DEPLOYED or settings.STAGING_DEPLOYED:
@ -981,8 +948,7 @@ def do_update_message(user_profile, message_id, subject, content):
edit_history_event["prev_rendered_content"] = message.rendered_content
edit_history_event["prev_rendered_content_version"] = message.rendered_content_version
message.content = content
message.rendered_content = rendered_content
message.rendered_content_version = bugdown.version
message.set_rendered_content(rendered_content)
event["content"] = content
event["rendered_content"] = rendered_content
@ -1014,8 +980,7 @@ def do_update_message(user_profile, message_id, subject, content):
cache_save_message(message)
items_for_memcached = {}
items_for_memcached[to_dict_cache_key(message, True)] = \
(stringify_message_dict(message.to_dict_uncached(apply_markdown=True,
rendered_content=message.rendered_content)),)
(stringify_message_dict(message.to_dict_uncached(apply_markdown=True)),)
items_for_memcached[to_dict_cache_key(message, False)] = \
(stringify_message_dict(message.to_dict_uncached(apply_markdown=False)),)
cache_set_many(items_for_memcached)

View File

@ -21,6 +21,7 @@ from zephyr.lib.bugdown import codehilite, fenced_code
from zephyr.lib.bugdown.fenced_code import FENCE_RE
from zephyr.lib.timeout import timeout, TimeoutExpired
from zephyr.lib.cache import cache_with_key, cache_get_many, cache_set_many
import zephyr.lib.mention as mention
if settings.USING_EMBEDLY:
@ -517,6 +518,30 @@ class RealmFilterPattern(markdown.inlinepatterns.Pattern):
return url_to_a(self.format_string % m.groupdict(),
m.group("name"))
class UserMentionPattern(markdown.inlinepatterns.Pattern):
def handleMatch(self, m):
name = m.group(2) or m.group(3)
if current_message:
wildcard, user = mention.find_user_for_mention(name, current_message.sender.realm)
if wildcard:
current_message.mentions_wildcard = True
email = "*"
elif user:
current_message.mentions_user_ids.add(user.id)
name = user.full_name
email = user.email
else:
# Don't highlight @mentions that don't refer to a valid user
return None
el = markdown.util.etree.Element("span")
el.set('class', 'user-mention')
el.set('data-user-email', email)
el.text = "@%s" % (name,)
return el
class Bugdown(markdown.Extension):
def extendMarkdown(self, md, md_globals):
del md.preprocessors['reference']
@ -538,6 +563,7 @@ class Bugdown(markdown.Extension):
md.parser.blockprocessors.add('ulist', UListProcessor(md.parser), '>hr')
md.inlinePatterns.add('gravatar', Gravatar(r'!gravatar\((?P<email>[^)]*)\)'), '_begin')
md.inlinePatterns.add('usermention', UserMentionPattern(mention.find_mentions), '>backtick')
md.inlinePatterns.add('emoji', Emoji(r'(?<!\S)(?P<syntax>:[^:\s]+:)(?!\S)'), '_begin')
md.inlinePatterns.add('link', LinkPattern(markdown.inlinepatterns.LINK_RE, md), '>backtick')
@ -615,16 +641,23 @@ _privacy_re = re.compile(r'\w', flags=re.UNICODE)
def _sanitize_for_log(md):
return repr(_privacy_re.sub('x', md))
def do_convert(md, realm):
# Filters such as UserMentionPattern need a message, but python-markdown
# provides no way to pass extra params through to a pattern. Thus, a global.
current_message = None
def do_convert(md, realm_domain=None, message=None):
"""Convert Markdown to HTML, with Humbug-specific settings and hacks."""
if realm in md_engines:
_md_engine = md_engines[realm]
if realm_domain in md_engines:
_md_engine = md_engines[realm_domain]
else:
_md_engine = md_engines["default"]
# Reset the parser; otherwise it will get slower over time.
_md_engine.reset()
global current_message
current_message = message
try:
# Spend at most 5 seconds rendering.
# Sometimes Python-Markdown is really slow; see
@ -645,7 +678,8 @@ def do_convert(md, realm):
cleaned, traceback.format_exc()),
fail_silently=False)
return None
finally:
current_message = None
bugdown_time_start = 0
bugdown_total_time = 0
@ -668,8 +702,8 @@ def bugdown_stats_finish():
bugdown_total_requests += 1
bugdown_total_time += (time.time() - bugdown_time_start)
def convert(md, realm):
def convert(md, realm_domain=None, message=None):
bugdown_stats_start()
ret = do_convert(md, realm)
ret = do_convert(md, realm_domain, message)
bugdown_stats_finish()
return ret

25
zephyr/lib/mention.py Normal file
View File

@ -0,0 +1,25 @@
import re
from django.db.models import F, Q
import zephyr.models
# Match multi-word string between @** ** or match any one-word
# sequences after @
find_mentions = r'(?:\B@(?:\*\*([^\*]+)\*\*)|@(\w+))'
find_mentions_re = re.compile(find_mentions)
wildcards = ['all', 'everyone']
def find_user_for_mention(mention, realm):
if mention in wildcards:
return (True, None)
try:
user = zephyr.models.UserProfile.objects.filter(
Q(full_name__iexact=mention) | Q(short_name__iexact=mention),
realm=realm
)[0]
except IndexError:
user = None
return (False, user)

View File

@ -339,14 +339,48 @@ class Message(models.Model):
def __str__(self):
return self.__repr__()
def to_dict(self, apply_markdown, rendered_content=None):
return extract_message_dict(self.to_dict_json(apply_markdown, rendered_content))
def render_markdown(self, content):
"""Return HTML for given markdown. Bugdown may add properties to the
message object such as `mentions_user_ids` and `mentions_wildcard`.
These are only on this Django object and are not saved in the
database.
"""
self.mentions_wildcard = False
self.mentions_user_ids = set()
return bugdown.convert(content, self.sender.realm.domain, self)
def set_rendered_content(self, rendered_content):
"""Set the content on the message.
This method does not call .save(). Save afterwards with
update_fields=["rendered_content", "rendered_content_version"].
"""
self.rendered_content = rendered_content
self.rendered_content_version = bugdown.version
if self.rendered_content is None:
return False
return True
def maybe_render_content(self):
"""Render the markdown if there is no existing rendered_content"""
if self.rendered_content is None:
return self.set_rendered_content(self.render_markdown(self.content))
else:
return True
def to_dict(self, apply_markdown):
return extract_message_dict(self.to_dict_json(apply_markdown))
@cache_with_key(to_dict_cache_key, timeout=3600*24)
def to_dict_json(self, apply_markdown, rendered_content=None):
return stringify_message_dict(self.to_dict_uncached(apply_markdown, rendered_content))
def to_dict_json(self, apply_markdown):
return stringify_message_dict(self.to_dict_uncached(apply_markdown))
def to_dict_uncached(self, apply_markdown, rendered_content=None):
def to_dict_uncached(self, apply_markdown):
display_recipient = get_display_recipient(self.recipient)
if self.recipient.type == Recipient.STREAM:
display_type = "stream"
@ -388,16 +422,11 @@ class Message(models.Model):
obj['content'] = self.rendered_content
obj['content_type'] = 'text/html'
elif apply_markdown:
if rendered_content is None:
rendered_content = bugdown.convert(self.content, self.sender.realm.domain)
if rendered_content is None:
rendered_content = '<p>[Humbug note: Sorry, we could not understand the formatting of your message]</p>'
if self.rendered_content is not None:
obj['content'] = self.rendered_content
else:
obj['content'] = '<p>[Humbug note: Sorry, we could not understand the formatting of your message]</p>'
# Update the database cache of the rendered content
self.rendered_content = rendered_content
self.rendered_content_version = bugdown.version
self.save(update_fields=["rendered_content", "rendered_content_version"])
obj['content'] = rendered_content
obj['content_type'] = 'text/html'
else:
obj['content'] = self.content

View File

@ -385,6 +385,15 @@ MessageList.prototype = {
if (ids_where_next_is_same_sender[id]) {
row.find('.messagebox').addClass("next_is_same_sender");
}
if (row.hasClass('mention')) {
row.find('.user-mention').each(function () {
var email = $(this).attr('data-user-email');
if (email === '*' || email === page_params.email) {
$(this).addClass('user-mention-me');
}
});
}
});
// The message that was last before this batch came in has to be

View File

@ -1924,6 +1924,20 @@ li.expanded_subject {
border: 1px solid #000;
}
.user-mention {
background: #eee;
border-radius: 6px;
padding: 0px 0.3em;
border: 1px solid #ccc;
font-weight: bold;
}
.user-mention-me {
background: #58F;
color: white;
border-color: #7080A5;
}
#notification-settings .control-group {
margin-bottom: 5px;
}