[feature] mention users in comments. Fixes frappe/erpnext#3495

This commit is contained in:
Anand Doshi 2015-12-23 20:14:46 +05:30
parent 751e36a533
commit 389219da70
11 changed files with 351 additions and 8 deletions

View file

@ -119,7 +119,7 @@ def get_fullnames():
ret = frappe.db.sql("""select name,
concat(ifnull(first_name, ''),
if(ifnull(last_name, '')!='', ' ', ''), ifnull(last_name, '')) as fullname,
user_image as image, gender, email
user_image as image, gender, email, username
from tabUser where enabled=1 and user_type!="Website User" """, as_dict=1)
d = {}

View file

@ -7,7 +7,8 @@ from frappe import _
from frappe.website.render import clear_cache
from frappe.model.document import Document
from frappe.model.db_schema import add_column
from frappe.utils import get_fullname
from frappe.utils import get_fullname, get_link_to_form
from frappe.core.doctype.user.user import extract_mentions
exclude_from_linked_with = True
@ -38,6 +39,7 @@ class Comment(Document):
"""Send realtime updates"""
if not self.comment_doctype:
return
if self.comment_doctype == 'Message':
if self.comment_docname == frappe.session.user:
message = self.as_dict()
@ -50,6 +52,8 @@ class Comment(Document):
frappe.publish_realtime('new_comment', self.as_dict(), doctype= self.comment_doctype,
docname = self.comment_docname)
self.notify_mentions()
def validate(self):
"""Raise exception for more than 50 comments."""
if frappe.db.sql("""select count(*) from tabComment where comment_doctype=%s
@ -144,6 +148,33 @@ class Comment(Document):
self.update_comments_in_parent(_comments)
def notify_mentions(self):
if self.comment_doctype and self.comment_docname and self.comment and self.comment_type=="Comment":
mentions = extract_mentions(self.comment)
if not mentions:
return
sender_fullname = get_fullname(frappe.session.user)
parent_doc_label = "{0} {1}".format(_(self.comment_doctype), self.comment_docname)
subject = _("{0} mentioned you in a comment in {1}").format(sender_fullname, parent_doc_label)
message = frappe.get_template("templates/emails/mentioned_in_comment.html").render({
"sender_fullname": sender_fullname,
"comment": self,
"link": get_link_to_form(self.comment_doctype, self.comment_docname, label=parent_doc_label)
})
recipients = [frappe.db.get_value("User", {"enabled": 1, "username": username, "user_type": "System User"})
for username in mentions]
frappe.sendmail(
recipients=recipients,
sender=frappe.session.user,
subject=subject,
message=message,
bulk=True
)
def on_doctype_update():
"""Add index to `tabComment` `(comment_doctype, comment_name)`"""
if not frappe.db.sql("""show index from `tabComment`

View file

@ -296,12 +296,13 @@ def validate_fields(meta):
if d.fieldtype not in ("Data", "Link", "Read Only"):
frappe.throw(_("Fieldtype {0} for {1} cannot be unique").format(d.fieldtype, d.label))
has_non_unique_values = frappe.db.sql("""select `{fieldname}`, count(*)
from `tab{doctype}` group by `{fieldname}` having count(*) > 1 limit 1""".format(
doctype=d.parent, fieldname=d.fieldname))
if not d.get("__islocal"):
has_non_unique_values = frappe.db.sql("""select `{fieldname}`, count(*)
from `tab{doctype}` group by `{fieldname}` having count(*) > 1 limit 1""".format(
doctype=d.parent, fieldname=d.fieldname))
if has_non_unique_values and has_non_unique_values[0][0]:
frappe.throw(_("Field '{0}' cannot be set as Unique as it has non-unique values").format(d.label))
if has_non_unique_values and has_non_unique_values[0][0]:
frappe.throw(_("Field '{0}' cannot be set as Unique as it has non-unique values").format(d.label))
if d.search_index and d.fieldtype in ("Text", "Long Text", "Small Text", "Code", "Text Editor"):
frappe.throw(_("Fieldtype {0} for {1} cannot be indexed").format(d.fieldtype, d.label))

View file

@ -24,6 +24,7 @@
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -49,6 +50,7 @@
"oldfieldtype": "Check",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -72,6 +74,7 @@
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -97,6 +100,7 @@
"options": "Email",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 1,
@ -121,6 +125,7 @@
"oldfieldtype": "Data",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 1,
@ -145,6 +150,7 @@
"oldfieldtype": "Data",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -169,6 +175,7 @@
"oldfieldtype": "Data",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -176,6 +183,30 @@
"set_only_once": 0,
"unique": 0
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"fieldname": "username",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"in_filter": 0,
"in_list_view": 0,
"label": "Username",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 1
},
{
"allow_on_submit": 0,
"bold": 0,
@ -194,6 +225,7 @@
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -216,6 +248,7 @@
"no_copy": 1,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -238,6 +271,7 @@
"oldfieldtype": "Column Break",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "50%",
"read_only": 0,
"report_hide": 0,
@ -264,6 +298,7 @@
"options": "Loading...",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -287,6 +322,7 @@
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -310,6 +346,7 @@
"no_copy": 1,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -333,6 +370,7 @@
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -358,6 +396,7 @@
"options": "\nMale\nFemale\nOther",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -382,6 +421,7 @@
"oldfieldtype": "Date",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -404,6 +444,7 @@
"no_copy": 1,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -425,6 +466,7 @@
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -447,6 +489,7 @@
"no_copy": 1,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -470,6 +513,7 @@
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -493,6 +537,7 @@
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -515,6 +560,7 @@
"no_copy": 1,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -539,6 +585,7 @@
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -561,6 +608,7 @@
"no_copy": 1,
"permlevel": 0,
"print_hide": 1,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -585,6 +633,7 @@
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -609,6 +658,7 @@
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -631,6 +681,7 @@
"no_copy": 1,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -655,6 +706,7 @@
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -678,6 +730,7 @@
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -702,6 +755,7 @@
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -726,6 +780,7 @@
"no_copy": 0,
"permlevel": 1,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -748,6 +803,7 @@
"no_copy": 0,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -771,6 +827,7 @@
"options": "UserRole",
"permlevel": 1,
"print_hide": 1,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -796,6 +853,7 @@
"permlevel": 1,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -819,6 +877,7 @@
"permlevel": 1,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -843,6 +902,7 @@
"permlevel": 1,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -867,6 +927,7 @@
"oldfieldtype": "Column Break",
"permlevel": 1,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "50%",
"read_only": 1,
"report_hide": 0,
@ -893,6 +954,7 @@
"options": "DefaultValue",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -917,6 +979,7 @@
"oldfieldtype": "Section Break",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -944,6 +1007,7 @@
"options": "System User\nWebsite User",
"permlevel": 1,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 1,
@ -967,6 +1031,7 @@
"no_copy": 0,
"permlevel": 1,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -990,6 +1055,7 @@
"no_copy": 0,
"permlevel": 1,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -1013,6 +1079,7 @@
"no_copy": 0,
"permlevel": 1,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -1035,6 +1102,7 @@
"oldfieldtype": "Column Break",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "50%",
"read_only": 0,
"report_hide": 0,
@ -1061,6 +1129,7 @@
"oldfieldtype": "Read Only",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -1085,6 +1154,7 @@
"oldfieldtype": "Read Only",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -1108,6 +1178,7 @@
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -1132,6 +1203,7 @@
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -1155,6 +1227,7 @@
"no_copy": 0,
"permlevel": 1,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -1177,6 +1250,7 @@
"no_copy": 1,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -1199,6 +1273,7 @@
"no_copy": 1,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -1221,6 +1296,7 @@
"no_copy": 1,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -1243,6 +1319,7 @@
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
@ -1265,6 +1342,7 @@
"no_copy": 1,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -1287,6 +1365,7 @@
"no_copy": 1,
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 1,
"report_hide": 0,
"reqd": 0,
@ -1305,7 +1384,8 @@
"issingle": 0,
"istable": 0,
"max_attachments": 5,
"modified": "2015-11-16 06:29:59.828065",
"menu_index": 0,
"modified": "2015-12-23 02:45:19.261689",
"modified_by": "Administrator",
"module": "Core",
"name": "User",

View file

@ -10,6 +10,7 @@ from frappe.desk.notifications import clear_notifications
from frappe.utils.user import get_system_managers
import frappe.permissions
import frappe.share
import re
STANDARD_USERS = ("Guest", "Administrator")
@ -38,6 +39,8 @@ class User(Document):
self.update_gravatar()
self.ensure_unique_roles()
self.remove_all_roles_for_guest()
self.validate_username()
if self.language == "Loading...":
self.language = None
@ -296,6 +299,52 @@ class User(Document):
else:
exists.append(d.role)
def validate_username(self):
if not self.username and self.is_new():
self.username = frappe.scrub(self.first_name)
if not self.username:
return
# strip space and @
self.username = self.username.strip(" @")
if self.username_exists():
frappe.msgprint(_("Username {0} already exists"))
self.suggest_username()
raise frappe.DuplicateEntryError(self.username)
if not self.is_new():
old_username = self.get_db_value("username")
if old_username and self.username != old_username and "System Manager" not in frappe.get_roles():
frappe.throw(_("Only a System Manager can change Username"))
# should be made up of characters, numbers and underscore only
if not re.match(r"^[\w]+$", self.username):
frappe.throw(_("Username should not contain any special characters other than letters, numbers and underscore"))
def suggest_username(self):
def _check_suggestion(suggestion):
if self.username != suggestion and not self.username_exists(suggestion):
return suggestion
return None
# @firstname
username = _check_suggestion(frappe.scrub(self.first_name))
if not username:
# @firstname_last_name
username = _check_suggestion(frappe.scrub("{0} {1}".format(self.first_name, self.last_name or "")))
if username:
frappe.msgprint(_("Suggested Username: {0}").format(username))
return username
def username_exists(self, username=None):
return frappe.db.get_value("User", {"username": username or self.username, "name": ("!=", self.name)})
@frappe.whitelist()
def get_languages():
from frappe.translate import get_lang_dict
@ -490,3 +539,7 @@ def notifify_admin_access_to_system_manager(login_manager=None):
frappe.sendmail(recipients=get_system_managers(), subject=_("Administrator Logged In"),
message=message, bulk=True)
def extract_mentions(txt):
"""Find all instances of @username in the string.
The mentions will be separated by non-word characters or may appear at the start of the string"""
return re.findall(r'(?:[^\w]|^)@([\w]*)', txt)

View file

@ -108,3 +108,4 @@ frappe.patches.v6_6.fix_file_url
frappe.patches.v6_9.rename_burmese_language
frappe.patches.v6_11.rename_field_in_email_account
execute:frappe.create_folder(os.path.join(frappe.local.site_path, 'private', 'files'))
frappe.patches.v6_15.set_username

View file

View file

@ -0,0 +1,15 @@
import frappe
def execute():
frappe.reload_doctype("User")
# give preference to System Users
users = frappe.db.sql_list("""select name from `tabUser` order by if(user_type='System User', 0, 1)""")
for name in users:
user = frappe.get_doc("User", name)
if user.username:
continue
username = user.suggest_username()
if username:
user.db_set("username", username, update_modified=False)

View file

@ -4,6 +4,7 @@
<div>
<textarea style="height: 80px" style="margin-top: 10px;"
class="form-control"></textarea>
<input type="data" class="hidden mention-input">
</div>
<div class="media">
<span class="pull-left avatar avatar-medium">

View file

@ -40,7 +40,10 @@ frappe.ui.form.Comments = Class.extend({
this.list.on("click", ".toggle-blockquote", function() {
$(this).parent().siblings("blockquote").toggleClass("hidden");
});
this.setup_mentions();
},
refresh: function(scroll_to_end) {
var me = this;
@ -66,6 +69,7 @@ frappe.ui.form.Comments = Class.extend({
this.frm.sidebar.refresh_comments();
},
render_comment: function(c) {
var me = this;
this.prepare_comment(c);
@ -310,5 +314,155 @@ frappe.ui.form.Comments = Class.extend({
});
return last_email;
},
setup_mentions: function() {
var me = this;
this.cursor_from = this.cursor_to = 0
this.codes = $.ui.keyCode;
this.up = $.Event("keydown", {"keyCode": this.codes.UP});
this.down = $.Event("keydown", {"keyCode": this.codes.DOWN});
this.enter = $.Event("keydown", {"keyCode": this.codes.ENTER});
this.setup_autocomplete_for_mentions();
this.setup_textarea_event();
},
setup_autocomplete_for_mentions: function() {
var me = this;
var username_user_map = {};
for (var name in frappe.boot.user_info) {
var _user = frappe.boot.user_info[name];
username_user_map[_user.username] = _user;
}
this.mention_input = this.wrapper.find(".mention-input");
this.mention_input.autocomplete({
minLength: 0,
autoFocus: true,
source: Object.keys(username_user_map),
select: function(event, ui) {
var value = ui.item.value;
var textarea_value = me.input.val();
var new_value = textarea_value.substring(0, me.cursor_from)
+ value
+ textarea_value.substring(me.cursor_to);
me.input.val(new_value);
var new_cursor_location = me.cursor_from + value.length;
// move cursor to right position
if (me.input[0].setSelectionRange) {
me.input.focus();
me.input[0].setSelectionRange(new_cursor_location, new_cursor_location);
} else if (me.input[0].createTextRange) {
var range = input[0].createTextRange();
range.collapse(true);
range.moveEnd('character', new_cursor_location);
range.moveStart('character', new_cursor_location);
range.select();
} else {
me.input.focus();
}
}
});
this.mention_widget = this.mention_input.autocomplete("widget");
this.autocomplete_open = false;
this.mention_input
.on('autocompleteclose', function() {
me.autocomplete_open = false;
})
.on('autocompleteopen', function() {
me.autocomplete_open = true;
});
// dirty hack to prevent backspace from navigating back to history
$(document).on("keydown", function(e) {
if (e.which===me.codes.BACKSPACE && me.autocomplete_open && document.activeElement==me.mention_widget.get(0)) {
// me.input.focus();
return false;
}
});
},
setup_textarea_event: function() {
var me = this;
// binding this in keyup to get the value after it is set in textarea
this.input.keyup(function(e) {
if (e.which===16) {
// don't trigger for shift
return;
} else if ([me.codes.UP, me.codes.DOWN].indexOf(e.which)!==-1) {
// focus on autocomplete if up and down arrows
if (me.autocomplete_open) {
me.mention_widget.focus();
me.mention_widget.trigger(e.which===me.codes.UP ? me.up : me.down);
}
return;
} else if ([me.codes.ENTER, me.codes.ESCAPE, me.codes.TAB, me.codes.SPACE].indexOf(e.which)!==-1) {
me.mention_input.autocomplete("close");
return;
} else if (e.which !== 0 && !e.ctrlKey && !e.metaKey && !e.altKey) {
if(!String.fromCharCode(e.which)) {
// no point in parsing it if it is not a character key
return;
}
}
var value = $(this).val() || "";
var i = e.target.selectionStart;
var key = value[i-1];
var substring = value.substring(0, i);
var mention = substring.match(/(?=[^\w]|^)@([\w]*)$/);
if (mention && mention.length) {
var mention = mention[0].slice(1);
// record location of cursor
me.cursor_from = i - mention.length;
me.cursor_to = i;
// render autocomplete at the bottom of the textbox and search for mention
me.mention_input.autocomplete("option", "position", {
of: me.input,
my: "left top",
at: "left bottom"
});
me.mention_input.autocomplete("search", mention);
} else {
me.cursor_from = me.cursor_to = 0;
me.mention_input.autocomplete("close");
}
});
// binding this in keydown to prevent default action
this.input.keydown(function(e) {
// enter, escape, tab
if (me.autocomplete_open) {
if ([me.codes.ENTER, me.codes.TAB].indexOf(e.which)!==-1) {
// set focused value
me.mention_widget.trigger(me.enter);
// prevent default
return false;
}
}
});
}
});

View file

@ -0,0 +1,7 @@
<p>
{{ _("{0} mentioned you in a comment in {1}").format(sender_fullname, link) }}
</p>
<blockquote
style="border-left: 3px solid #d1d8dd; padding: 7px 15px; margin-left: 0px;">
{{ comment.comment | markdown }}
</blockquote>