diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index bfcb2fa0..330f31f1 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -453,7 +453,7 @@ def process_inbox_request(request_json, activitypublog_id): activity_log.exception_message = 'Activity about local content which is already present' activity_log.result = 'ignored' - # Announce is new content and votes, lemmy and mastodon style + # Announce is new content and votes that happened on a remote server. if request_json['type'] == 'Announce': if request_json['object']['type'] == 'Create': activity_log.activity_type = request_json['object']['type'] @@ -511,6 +511,7 @@ def process_inbox_request(request_json, activitypublog_id): if post is not None: db.session.add(post) community.post_count += 1 + activity_log.result = 'success' db.session.commit() if post.image_id: make_image_sizes(post.image_id, 266, None, 'posts') @@ -623,6 +624,39 @@ def process_inbox_request(request_json, activitypublog_id): to_be_deleted_ap_id = request_json['object']['object'] delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id) activity_log.result = 'success' + elif request_json['object']['type'] == 'Page': # Editing a post + post = Post.query.filter_by(ap_id=request_json['object']['id']).first() + if post: + post.title = request_json['object']['name'] + if 'source' in request_json['object'] and request_json['object']['source']['mediaType'] == 'text/markdown': + post.body = request_json['object']['source']['content'] + post.body_html = markdown_to_html(post.body) + elif 'content' in request_json['object']: + post.body_html = allowlist_html(request_json['object']['content']) + post.body = html_to_markdown(post.body_html) + if 'attachment' in request_json['object'] and 'href' in request_json['object']['attachment']: + post.url = request_json['object']['attachment']['href'] + post.edited_at = utcnow() + db.session.commit() + activity_log.result = 'success' + else: + activity_log.exception_message = 'Post not found' + elif request_json['object']['type'] == 'Note': # Editing a reply + reply = PostReply.query.filter_by(ap_id=request_json['object']['id']).first() + if reply: + if 'source' in request_json['object'] and request_json['object']['source']['mediaType'] == 'text/markdown': + reply.body = request_json['object']['source']['content'] + reply.body_html = markdown_to_html(reply.body) + elif 'content' in request_json['object']: + reply.body_html = allowlist_html(request_json['object']['content']) + reply.body = html_to_markdown(reply.body_html) + reply.edited_at = utcnow() + db.session.commit() + activity_log.result = 'success' + else: + activity_log.exception_message = 'PostReply not found' + else: + activity_log.exception_message = 'Invalid type for Announce' # Follow: remote user wants to join/follow one of our communities elif request_json['type'] == 'Follow': # Follow is when someone wants to join a community @@ -781,6 +815,7 @@ def process_inbox_request(request_json, activitypublog_id): activity_log.result = 'ignored' elif request_json['type'] == 'Update': + activity_log.activity_type = 'Update' if request_json['object']['type'] == 'Page': # Editing a post post = Post.query.filter_by(ap_id=request_json['object']['id']).first() if post: @@ -790,9 +825,13 @@ def process_inbox_request(request_json, activitypublog_id): elif 'content' in request_json['object']: post.body_html = allowlist_html(request_json['object']['content']) post.body = html_to_markdown(post.body_html) + if 'attachment' in request_json['object'] and 'href' in request_json['object']['attachment']: + post.url = request_json['object']['attachment']['href'] post.edited_at = utcnow() db.session.commit() activity_log.result = 'success' + else: + activity_log.exception_message = 'Post not found' elif request_json['object']['type'] == 'Note': # Editing a reply reply = PostReply.query.filter_by(ap_id=request_json['object']['id']).first() if reply: @@ -805,6 +844,8 @@ def process_inbox_request(request_json, activitypublog_id): reply.edited_at = utcnow() db.session.commit() activity_log.result = 'success' + else: + activity_log.exception_message = 'PostReply not found' elif request_json['type'] == 'Delete': if isinstance(request_json['object'], str): ap_id = request_json['object'] # lemmy diff --git a/app/community/routes.py b/app/community/routes.py index cf70a91d..eab6ac06 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -1,4 +1,4 @@ -from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort +from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort, g from flask_login import login_user, logout_user, current_user, login_required from flask_babel import _ from sqlalchemy import or_, desc @@ -9,7 +9,7 @@ from app.activitypub.util import default_context from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm, ReportCommunityForm, \ DeleteCommunityForm from app.community.util import search_for_community, community_url_exists, actor_to_community, \ - opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file + opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \ SUBSCRIPTION_PENDING from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ @@ -303,59 +303,83 @@ def add_post(actor): form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] if form.validate_on_submit(): + community = Community.query.get_or_404(form.communities.data) post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) save_post(form, post) community.post_count += 1 - community.last_active = utcnow() + community.last_active = g.site.last_active = utcnow() db.session.commit() post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}" db.session.commit() + page = { + 'type': 'Page', + 'id': post.ap_id, + 'attributedTo': current_user.ap_profile_id, + 'to': [ + community.ap_profile_id, + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'name': post.title, + 'cc': [], + 'content': post.body_html, + 'mediaType': 'text/html', + 'source': { + 'content': post.body, + 'mediaType': 'text/markdown' + }, + 'attachment': [], + 'commentsEnabled': post.comments_enabled, + 'sensitive': post.nsfw, + 'nsfl': post.nsfl, + 'published': ap_datetime(utcnow()), + 'audience': community.ap_profile_id + } + create = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", + "actor": current_user.ap_profile_id, + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + community.ap_profile_id + ], + "type": "Create", + "audience": community.ap_profile_id, + "object": page, + '@context': default_context() + } if not community.is_local(): # this is a remote community - send the post to the instance that hosts it - page = { - 'type': 'Page', - 'id': post.ap_id, - 'attributedTo': current_user.ap_profile_id, - 'to': [ - community.ap_profile_id, - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'name': post.title, - 'cc': [], - 'content': post.body_html, - 'mediaType': 'text/html', - 'source': { - 'content': post.body, - 'mediaType': 'text/markdown' - }, - 'attachment': [], - 'commentsEnabled': post.comments_enabled, - 'sensitive': post.nsfw, - 'nsfl': post.nsfl, - 'published': ap_datetime(utcnow()), - 'audience': community.ap_profile_id - } - create = { - "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", - "actor": current_user.ap_profile_id, - "to": [ - "https://www.w3.org/ns/activitystreams#Public" - ], - "cc": [ - community.ap_profile_id - ], - "type": "Create", - "audience": community.ap_profile_id, - "object": page - } success = post_request(community.ap_inbox_url, create, current_user.private_key, current_user.ap_profile_id + '#main-key') if success: - flash('Your post has been sent to ' + community.title) + flash(_('Your post to %(name)s has been made.', name=community.title)) else: - flash('There was a problem sending your post to ' + community.title) + flash('There was a problem making your post to ' + community.title) else: # local community - send post out to followers - ... + announce = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", + "type": 'Announce', + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "actor": community.ap_profile_id, + "cc": [ + community.ap_followers_url + ], + '@context': default_context(), + 'object': create + } + + sent_to = 0 + for instance in community.following_instances(): + if instance[1] and not current_user.has_blocked_instance(instance[0]): + send_to_remote_instance(instance[1], community.id, announce) + sent_to += 1 + if sent_to: + flash(_('Your post to %(name)s has been made.', name=community.title)) + else: + flash(_('Your post to %(name)s has been made.', name=community.title)) return redirect(f"/c/{community.link()}") else: @@ -366,8 +390,8 @@ def add_post(actor): images_disabled=images_disabled, markdown_editor=True) -@login_required @bp.route('/community//report', methods=['GET', 'POST']) +@login_required def community_report(community_id: int): community = Community.query.get_or_404(community_id) form = ReportCommunityForm() @@ -396,8 +420,8 @@ def community_report(community_id: int): return render_template('community/community_report.html', title=_('Report community'), form=form, community=community) -@login_required @bp.route('/community//delete', methods=['GET', 'POST']) +@login_required def community_delete(community_id: int): community = Community.query.get_or_404(community_id) if community.is_owner() or current_user.is_admin(): @@ -415,8 +439,8 @@ def community_delete(community_id: int): abort(401) -@login_required @bp.route('/community//block_instance', methods=['GET', 'POST']) +@login_required def community_block_instance(community_id: int): community = Community.query.get_or_404(community_id) existing = InstanceBlock.query.filter_by(user_id=current_user.id, instance_id=community.instance_id).first() diff --git a/app/community/util.py b/app/community/util.py index 01b06a36..54609d03 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -9,6 +9,7 @@ from flask_login import current_user from pillow_heif import register_heif_opener from app import db, cache, celery +from app.activitypub.signature import post_request from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site @@ -350,4 +351,18 @@ def save_banner_file(banner_file, directory='communities') -> File: file = File(file_path=final_place, file_name=new_filename + file_ext, alt_text=f'{directory} banner', width=img_width, height=img_height, thumbnail_width=thumbnail_width, thumbnail_height=thumbnail_height) db.session.add(file) - return file \ No newline at end of file + return file + + +def send_to_remote_instance(inbox, community_id, payload): + if current_app.debug: + send_to_remote_instance_task(inbox, community_id, payload) + else: + send_to_remote_instance_task.delay(inbox, community_id, payload) + + +@celery.task +def send_to_remote_instance_task(inbox, community_id, payload): + community = Community.query.get(community_id) + if community: + post_request(inbox, payload, community.private_key, community.ap_profile_id + '#main-key') diff --git a/app/models.py b/app/models.py index 28dec12f..74ba3f66 100644 --- a/app/models.py +++ b/app/models.py @@ -201,6 +201,12 @@ class Community(db.Model): else: return f"https://{current_app.config['SERVER_NAME']}/c/{self.ap_id}" + # returns a list of tuples (instance.id, instance.inbox) + def following_instances(self): + sql = 'select distinct i.id, i.inbox from "instance" as i inner join "user" as u on u.instance_id = i.id inner join "community_member" as cm on cm.user_id = u.id ' + sql += 'where cm.community_id = :community_id and cm.is_banned = false' + return db.session.execute(text(sql), {'community_id': self.id}) + def delete_dependencies(self): # this will be fine for remote communities but for local ones it is necessary to federate every deletion out to subscribers for post in self.posts: @@ -410,6 +416,10 @@ class User(UserMixin, db.Model): def created_recently(self): return self.created and self.created > utcnow() - timedelta(days=7) + def has_blocked_instance(self, instance_id): + instance_block = InstanceBlock.query.filter_by(user_id=self.id, instance_id=instance_id).first() + return instance_block is not None + @staticmethod def verify_reset_password_token(token): try: diff --git a/app/post/routes.py b/app/post/routes.py index b35b3da3..1248ae19 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -8,7 +8,7 @@ from sqlalchemy import or_, desc from app import db, constants from app.activitypub.signature import HttpSignature, post_request from app.activitypub.util import default_context -from app.community.util import save_post +from app.community.util import save_post, send_to_remote_instance from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm from app.community.forms import CreatePostForm from app.post.util import post_replies, get_comment_branch, post_reply_count @@ -68,48 +68,63 @@ def show_post(post_id: int): post.flush_cache() # federation + reply_json = { + 'type': 'Note', + 'id': reply.profile_id(), + 'attributedTo': current_user.profile_id(), + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'cc': [ + post.community.profile_id(), + ], + 'content': reply.body_html, + 'inReplyTo': post.profile_id(), + 'mediaType': 'text/html', + 'source': { + 'content': reply.body, + 'mediaType': 'text/markdown' + }, + 'published': ap_datetime(utcnow()), + 'distinguished': False, + 'audience': post.community.profile_id() + } + create_json = { + 'type': 'Create', + 'actor': current_user.profile_id(), + 'audience': post.community.profile_id(), + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'cc': [ + post.community.ap_profile_id + ], + 'object': reply_json, + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}" + } if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it - reply_json = { - 'type': 'Note', - 'id': reply.profile_id(), - 'attributedTo': current_user.profile_id(), - 'to': [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'cc': [ - post.community.profile_id(), - ], - 'content': reply.body_html, - 'inReplyTo': post.profile_id(), - 'mediaType': 'text/html', - 'source': { - 'content': reply.body, - 'mediaType': 'text/markdown' - }, - 'published': ap_datetime(utcnow()), - 'distinguished': False, - 'audience': post.community.profile_id() - } - create_json = { - 'type': 'Create', - 'actor': current_user.profile_id(), - 'audience': post.community.profile_id(), - 'to': [ - 'https://www.w3.org/ns/activitystreams#Public' - ], - 'cc': [ - post.community.ap_profile_id - ], - 'object': reply_json, - 'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}" - } - success = post_request(post.community.ap_inbox_url, create_json, current_user.private_key, current_user.ap_profile_id + '#main-key') if not success: flash('Failed to send to remote instance', 'error') else: # local community - send it to followers on remote instances - ... + announce = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", + "type": 'Announce', + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "actor": post.community.ap_profile_id, + "cc": [ + post.community.ap_followers_url + ], + '@context': default_context(), + 'object': create_json + } + + for instance in post.community.following_instances(): + if instance[1] and not current_user.has_blocked_instance(instance[0]): + send_to_remote_instance(instance[1], post.community.id, announce) return redirect(url_for('activitypub.post_ap', post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form @@ -319,64 +334,81 @@ def add_reply(post_id: int, comment_id: int): post.flush_cache() # federation - if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it - reply_json = { - 'type': 'Note', - 'id': reply.profile_id(), - 'attributedTo': current_user.profile_id(), - 'to': [ - 'https://www.w3.org/ns/activitystreams#Public', - in_reply_to.author.profile_id() - ], - 'cc': [ - post.community.profile_id(), - current_user.followers_url() - ], - 'content': reply.body_html, - 'inReplyTo': in_reply_to.profile_id(), - 'url': reply.profile_id(), - 'mediaType': 'text/html', - 'source': { - 'content': reply.body, - 'mediaType': 'text/markdown' - }, - 'published': ap_datetime(utcnow()), - 'distinguished': False, - 'audience': post.community.profile_id(), - 'contentMap': { - 'en': reply.body_html + reply_json = { + 'type': 'Note', + 'id': reply.profile_id(), + 'attributedTo': current_user.profile_id(), + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public', + in_reply_to.author.profile_id() + ], + 'cc': [ + post.community.profile_id(), + current_user.followers_url() + ], + 'content': reply.body_html, + 'inReplyTo': in_reply_to.profile_id(), + 'url': reply.profile_id(), + 'mediaType': 'text/html', + 'source': { + 'content': reply.body, + 'mediaType': 'text/markdown' + }, + 'published': ap_datetime(utcnow()), + 'distinguished': False, + 'audience': post.community.profile_id(), + 'contentMap': { + 'en': reply.body_html + } + } + create_json = { + '@context': default_context(), + 'type': 'Create', + 'actor': current_user.profile_id(), + 'audience': post.community.profile_id(), + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public', + in_reply_to.author.profile_id() + ], + 'cc': [ + post.community.profile_id(), + current_user.followers_url() + ], + 'object': reply_json, + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}" + } + if in_reply_to.notify_author and in_reply_to.author.ap_id is not None: + reply_json['tag'] = [ + { + 'href': in_reply_to.author.ap_profile_id, + 'name': '@' + in_reply_to.author.ap_id, + 'type': 'Mention' } - } - create_json = { - '@context': default_context(), - 'type': 'Create', - 'actor': current_user.profile_id(), - 'audience': post.community.profile_id(), - 'to': [ - 'https://www.w3.org/ns/activitystreams#Public', - in_reply_to.author.profile_id() - ], - 'cc': [ - post.community.profile_id(), - current_user.followers_url() - ], - 'object': reply_json, - 'id': f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}" - } - if in_reply_to.notify_author and in_reply_to.author.ap_id is not None: - reply_json['tag'] = [ - { - 'href': in_reply_to.author.ap_profile_id, - 'name': '@' + in_reply_to.author.ap_id, - 'type': 'Mention' - } - ] + ] + if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it success = post_request(post.community.ap_inbox_url, create_json, current_user.private_key, current_user.ap_profile_id + '#main-key') if not success: flash('Failed to send reply', 'error') else: # local community - send it to followers on remote instances - ... + announce = { + "id": f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", + "type": 'Announce', + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "actor": post.community.ap_profile_id, + "cc": [ + post.community.ap_followers_url + ], + '@context': default_context(), + 'object': create_json + } + + for instance in post.community.following_instances(): + if instance[1] and not current_user.has_blocked_instance(instance[0]): + send_to_remote_instance(instance[1], post.community.id, announce) + if reply.depth <= constants.THREAD_CUTOFF_DEPTH: return redirect(url_for('activitypub.post_ap', post_id=post_id, _anchor=f'comment_{reply.parent_id}')) else: @@ -392,8 +424,9 @@ def post_options(post_id: int): post = Post.query.get_or_404(post_id) return render_template('post/post_options.html', post=post) -@login_required + @bp.route('/post//edit', methods=['GET', 'POST']) +@login_required def post_edit(post_id: int): post = Post.query.get_or_404(post_id) form = CreatePostForm() @@ -427,13 +460,14 @@ def post_edit(post_id: int): form.type.data = 'image' form.image_title.data = post.title form.notify_author.data = post.notify_author - return render_template('post/post_edit.html', title=_('Edit post'), form=form, post=post, images_disabled=images_disabled) + return render_template('post/post_edit.html', title=_('Edit post'), form=form, post=post, + images_disabled=images_disabled, markdown_editor=True) else: abort(401) -@login_required @bp.route('/post//delete', methods=['GET', 'POST']) +@login_required def post_delete(post_id: int): post = Post.query.get_or_404(post_id) community = post.community @@ -447,8 +481,8 @@ def post_delete(post_id: int): return redirect(url_for('activitypub.community_profile', actor=community.ap_id if community.ap_id is not None else community.name)) -@login_required @bp.route('/post//report', methods=['GET', 'POST']) +@login_required def post_report(post_id: int): post = Post.query.get_or_404(post_id) form = ReportPostForm() @@ -479,8 +513,8 @@ def post_report(post_id: int): return render_template('post/post_report.html', title=_('Report post'), form=form, post=post) -@login_required @bp.route('/post//block_user', methods=['GET', 'POST']) +@login_required def post_block_user(post_id: int): post = Post.query.get_or_404(post_id) existing = UserBlock.query.filter_by(blocker_id=current_user.id, blocked_id=post.author.id).first() @@ -494,8 +528,8 @@ def post_block_user(post_id: int): return redirect(post.community.local_url()) -@login_required @bp.route('/post//block_domain', methods=['GET', 'POST']) +@login_required def post_block_domain(post_id: int): post = Post.query.get_or_404(post_id) existing = DomainBlock.query.filter_by(user_id=current_user.id, domain_id=post.domain_id).first() @@ -506,8 +540,8 @@ def post_block_domain(post_id: int): return redirect(post.community.local_url()) -@login_required @bp.route('/post//block_instance', methods=['GET', 'POST']) +@login_required def post_block_instance(post_id: int): post = Post.query.get_or_404(post_id) existing = InstanceBlock.query.filter_by(user_id=current_user.id, instance_id=post.instance_id).first() @@ -518,8 +552,8 @@ def post_block_instance(post_id: int): return redirect(post.community.local_url()) -@login_required @bp.route('/post//mea_culpa', methods=['GET', 'POST']) +@login_required def post_mea_culpa(post_id: int): post = Post.query.get_or_404(post_id) form = MeaCulpaForm() diff --git a/app/static/js/markdown/downarea.css b/app/static/js/markdown/downarea.css index c9b2e41e..a952ffde 100644 --- a/app/static/js/markdown/downarea.css +++ b/app/static/js/markdown/downarea.css @@ -1,5 +1,3 @@ -@import url('https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300;0,400;0,600;0,700;0,800;1,300;1,400;1,600;1,700;1,800&display=swap'); - .downarea, .downarea * { margin: 0; padding: 0; @@ -7,7 +5,6 @@ -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; - font-family: 'Open Sans', -apple-system, 'Segoe UI', sans-serif; } .downarea::-webkit-scrollbar , .downarea *::-webkit-scrollbar { @@ -195,8 +192,6 @@ padding: 8px; margin-bottom: 20px; resize: none; - font-size: 14px; - font-weight: 500; } .downarea .downarea-bottom { diff --git a/app/static/styles.css b/app/static/styles.css index 271e5b72..a62eae67 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -532,6 +532,10 @@ nav.navbar { text-decoration: none; } +.alert { + width: 96%; +} + @media (prefers-color-scheme: dark) { body { background-color: #777; diff --git a/app/static/styles.scss b/app/static/styles.scss index d48175a8..e9c3a51f 100644 --- a/app/static/styles.scss +++ b/app/static/styles.scss @@ -239,6 +239,11 @@ nav.navbar { width: 41px; text-decoration: none; } + +.alert { + width: 96%; +} + @media (prefers-color-scheme: dark) { body { background-color: $dark-grey; diff --git a/app/templates/post/post_edit.html b/app/templates/post/post_edit.html index 2bcd5338..dce5e9af 100644 --- a/app/templates/post/post_edit.html +++ b/app/templates/post/post_edit.html @@ -24,6 +24,13 @@ if(toClick) { toClick.click(); } + + var downarea = new DownArea({ + elem: document.querySelector('#discussion_body'), + resize: DownArea.RESIZE_VERTICAL, + hide: ['heading', 'bold-italic'], + value: {{ form.discussion_body.data | tojson | safe }} + }); });