diff --git a/app/__init__.py b/app/__init__.py index 01c0872..b54a62e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -40,10 +40,11 @@ def create_admin(): def create_deleted_user(): username = "DeletedUser" - if Users.count({"username": username}) == 0: + if Users.count({"username": username.lower()}) == 0: print("Creating DeletedUser") Users.create({ - "username": username, + "username": username.lower(), + "display_name": username, "password_hash": "", "permission": PermissionLevel.SYSTEM.value, }) @@ -61,7 +62,7 @@ def reparse_babycode(): with db.transaction(): for ph in post_histories: ph.update({ - 'content': babycode_to_html(ph['original_markup']), + 'content': babycode_to_html(ph['original_markup']).result, 'format_version': BABYCODE_VERSION, }) print('Re-parsing posts done.') @@ -76,7 +77,7 @@ def reparse_babycode(): with db.transaction(): for user in users_with_sigs: user.update({ - 'signature_rendered': babycode_to_html(user['signature_original_markup']), + 'signature_rendered': babycode_to_html(user['signature_original_markup']).result, 'signature_format_version': BABYCODE_VERSION, }) print(f'Re-parsed {len(users_with_sigs)} user sigs.') @@ -90,7 +91,7 @@ def reparse_babycode(): with db.transaction(): for motd in stale_motds: motd.update({ - 'body_rendered': babycode_to_html(motd['body_original_markup'], banned_tags=MOTD_BANNED_TAGS), + 'body_rendered': babycode_to_html(motd['body_original_markup'], banned_tags=MOTD_BANNED_TAGS).result, 'format_version': BABYCODE_VERSION, }) print('Re-parsing MOTDs done.') @@ -206,7 +207,7 @@ def create_app(): @app.template_filter('babycode') def babycode_filter(markup): - return babycode_to_html(markup) + return babycode_to_html(markup).result @app.template_filter('extract_h2') def extract_h2(content): diff --git a/app/constants.py b/app/constants.py index 495d4d3..e2f2081 100644 --- a/app/constants.py +++ b/app/constants.py @@ -48,7 +48,7 @@ REACTION_EMOJI = [ ] MOTD_BANNED_TAGS = [ - 'img', 'spoiler', + 'img', 'spoiler', 'mention' ] def permission_level_string(perm): diff --git a/app/lib/babycode.py b/app/lib/babycode.py index b622c79..58bbfe4 100644 --- a/app/lib/babycode.py +++ b/app/lib/babycode.py @@ -6,6 +6,16 @@ from pygments.lexers import get_lexer_by_name from pygments.util import ClassNotFound as PygmentsClassNotFound import re +class BabycodeParseResult: + def __init__(self, result, mentions=[]): + self.result = result + self.mentions = mentions + + + def __str__(self): + return self.result + + BABYCODE_VERSION = 5 NAMED_COLORS = [ @@ -78,6 +88,9 @@ def tag_list(children): return " ".join([f"
  • {x}
  • " for x in list_body.split("\1") if x]) def tag_color(children, attr, surrounding): + if not attr: + return f"[color]{children}[/color]" + hex_re = r"^#?([0-9a-f]{6}|[0-9a-f]{3})$" potential_color = attr.lower().strip() @@ -206,6 +219,24 @@ def is_inline(e): return e['type'] != 'rule' +def make_mention(e, mentions): + from ..models import Users + from flask import url_for + target_user = Users.find({'username': e['name'].lower()}) + if not target_user: + return f"@{e['name']}" + + mention_data = { + 'mention_text': f"@{e['name']}", + 'mentioned_user_id': int(target_user.id), + "start": e['start'], + "end": e['end'], + } + if mention_data not in mentions: + mentions.append(mention_data) + + return f"{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}" + def should_collapse(text, surrounding): if not isinstance(text, str): return False @@ -218,15 +249,19 @@ def should_collapse(text, surrounding): return False -def babycode_to_html(s, banned_tags=None): - allowed_tags = list(TAGS.keys()) +def sanitize(s): + return escape(s.strip().replace('\r\n', '\n').replace('\r', '\n')) + +def babycode_to_html(s, banned_tags={}): + allowed_tags = set(TAGS.keys()) if banned_tags is not None: for tag in banned_tags: - allowed_tags.remove(tag) - subj = escape(s.strip().replace('\r\n', '\n').replace('\r', '\n')) + allowed_tags.discard(tag) + subj = sanitize(s) parser = Parser(subj) parser.valid_bbcode_tags = allowed_tags parser.bbcode_tags_only_text_children = TEXT_ONLY + parser.mentions_allowed = '@mention' not in banned_tags parser.valid_emotes = EMOJI.keys() uncollapsed = parser.parse() @@ -241,6 +276,7 @@ def babycode_to_html(s, banned_tags=None): elements.append(e) out = "" + mentions = [] def fold(element, nobr, surrounding): if isinstance(element, str): if nobr: @@ -266,6 +302,8 @@ def babycode_to_html(s, banned_tags=None): return EMOJI[element['name']] case "rule": return "
    " + case "mention": + return make_mention(element, mentions) for i in range(len(elements)): e = elements[i] @@ -274,4 +312,4 @@ def babycode_to_html(s, banned_tags=None): elements[i + 1] if i+1 < len(elements) else None ) out = out + fold(e, False, surrounding) - return out + return BabycodeParseResult(out, mentions) diff --git a/app/lib/babycode_parser.py b/app/lib/babycode_parser.py index f05dd7e..a858f05 100644 --- a/app/lib/babycode_parser.py +++ b/app/lib/babycode_parser.py @@ -6,12 +6,14 @@ PAT_EMOTE = r"[^\s:]" PAT_BBCODE_TAG = r"\w" PAT_BBCODE_ATTR = r"[^\]]" PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]" +PAT_MENTION = r'[a-zA-Z0-9_-]' class Parser: def __init__(self, src_str): - self.valid_bbcode_tags = [] + self.valid_bbcode_tags = {} self.valid_emotes = [] - self.bbcode_tags_only_text_children = [], + self.bbcode_tags_only_text_children = [] + self.mentions_allowed = True self.source = src_str self.position = 0 self.position_stack = [] @@ -206,6 +208,25 @@ class Parser: "url": word } + def parse_mention(self): + if not self.mentions_allowed: + return None + + self.save_position() + + if not self.check_char('@'): + self.restore_position() + return None + + mention = self.match_pattern(PAT_MENTION) + self.forget_position() + return { + "type": "mention", + "name": mention, + "start": self.position - len(mention) - 1, + "end": self.position, + } + def parse_element(self, siblings): if self.is_end_of_source(): @@ -214,7 +235,8 @@ class Parser: element = self.parse_emote() \ or self.parse_bbcode() \ or self.parse_rule() \ - or self.parse_link() + or self.parse_link() \ + or self.parse_mention() if element is None: if len(siblings) > 0: diff --git a/app/migrations.py b/app/migrations.py index 3df9fb3..6def7de 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -22,6 +22,18 @@ def create_default_bookmark_collections(): for user in user_ids_without_default_collection: BookmarkCollections.create_default(user['id']) +def add_display_name(): + dq = 'ALTER TABLE "users" ADD COLUMN "display_name" TEXT NOT NULL DEFAULT ""' + db.execute(dq) + from .models import Users + for user in Users.select(): + data = { + 'username': user.username.lower(), + } + if user.username.lower() != user.username: + data['display_name'] = user.username + user.update(data) + # format: [str|tuple(str, any...)|callable] MIGRATIONS = [ migrate_old_avatars, @@ -30,6 +42,7 @@ MIGRATIONS = [ 'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL', add_signature_format, create_default_bookmark_collections, + add_display_name, ] def run_migrations(): diff --git a/app/models.py b/app/models.py index 0b90639..7554c69 100644 --- a/app/models.py +++ b/app/models.py @@ -53,7 +53,8 @@ class Users(Model): COUNT(DISTINCT threads.id) AS thread_count, MAX(threads.title) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_title, MAX(threads.slug) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_slug, - inviter.username AS inviter_username + inviter.username AS inviter_username, + inviter.display_name AS inviter_display_name FROM users LEFT JOIN posts ON posts.user_id = users.id LEFT JOIN threads ON threads.user_id = users.id @@ -106,6 +107,15 @@ class Users(Model): res = db.query(q, self.id) return [BookmarkCollections.find({'id': bc['id']}) for bc in res] + def get_readable_name(self): + if self.display_name: + return self.display_name + + return self.username + + def has_display_name(self): + return self.display_name != '' + class Topics(Model): table = "topics" @@ -116,6 +126,7 @@ class Topics(Model): SELECT topics.id, topics.name, topics.slug, topics.description, topics.is_locked, users.username AS latest_thread_username, + users.display_name AS latest_thread_display_name, threads.title AS latest_thread_title, threads.slug AS latest_thread_slug, threads.created_at AS latest_thread_created_at @@ -141,7 +152,7 @@ class Topics(Model): SELECT threads.topic_id, threads.id AS thread_id, threads.title AS thread_title, threads.slug AS thread_slug, posts.id AS post_id, posts.created_at AS post_created_at, - users.username, + users.username, users.display_name, ROW_NUMBER() OVER (PARTITION BY threads.topic_id ORDER BY posts.created_at DESC) AS rn FROM threads @@ -154,7 +165,7 @@ class Topics(Model): topic_id, thread_id, thread_title, thread_slug, post_id, post_created_at, - username + username, display_name FROM ranked_threads WHERE @@ -170,6 +181,7 @@ class Topics(Model): 'thread_slug': thread['thread_slug'], 'post_id': thread['post_id'], 'username': thread['username'], + 'display_name': thread['display_name'], 'post_created_at': thread['post_created_at'] } return active_threads @@ -185,7 +197,9 @@ class Topics(Model): SELECT threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied, users.username AS started_by, - u.username AS latest_post_username, + users.display_name AS started_by_display_name, + u.username AS latest_post_username, + u.display_name AS latest_post_display_name, ph.content AS latest_post_content, posts.created_at AS latest_post_created_at, posts.id AS latest_post_id @@ -230,7 +244,13 @@ class Threads(Model): class Posts(Model): FULL_POSTS_QUERY = """ SELECT - posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered, threads.slug AS thread_slug, threads.is_locked AS thread_is_locked, threads.title AS thread_title + posts.id, posts.created_at, + post_history.content, post_history.edited_at, + users.username, users.display_name, users.status, + avatars.file_path AS avatar_path, posts.thread_id, + users.id AS user_id, post_history.original_markup, + users.signature_rendered, threads.slug AS thread_slug, + threads.is_locked AS thread_is_locked, threads.title AS thread_title FROM posts JOIN @@ -410,3 +430,7 @@ class MOTD(Model): q = 'SELECT id FROM motd' res = db.query(q) return [MOTD.find({'id': i['id']}) for i in res] + + +class Mentions(Model): + table = 'mentions' diff --git a/app/routes/api.py b/app/routes/api.py index 3442906..9e0375b 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -41,7 +41,7 @@ def babycode_preview(): if not markup or not isinstance(markup, str): return {'error': 'markup field missing or invalid type'}, 400 banned_tags = request.json.get('banned_tags', []) - rendered = babycode_to_html(markup, banned_tags) + rendered = babycode_to_html(markup, banned_tags).result return {'html': rendered} @@ -213,3 +213,16 @@ def bookmark_thread(thread_id): return {'error': 'bad request'}, 400 return {'status': 'ok'}, 200 + +@bp.get('/current-user') +def get_current_user_info(): + if not is_logged_in(): + return {'user': null} + + user = get_active_user() + return { + 'user': { + 'username': user.username, + 'display_name': user.display_name, + } + } diff --git a/app/routes/mod.py b/app/routes/mod.py index 8b3534d..0c5199f 100644 --- a/app/routes/mod.py +++ b/app/routes/mod.py @@ -73,7 +73,7 @@ def motd_editor_form(): data = { 'title': title, 'body_original_markup': orig_body, - 'body_rendered': babycode_to_html(orig_body, banned_tags=MOTD_BANNED_TAGS), + 'body_rendered': babycode_to_html(orig_body, banned_tags=MOTD_BANNED_TAGS).result, 'format_version': BABYCODE_VERSION, 'edited_at': int(time.time()), } diff --git a/app/routes/posts.py b/app/routes/posts.py index 6138d54..8a8c38d 100644 --- a/app/routes/posts.py +++ b/app/routes/posts.py @@ -5,7 +5,7 @@ from .users import login_required, get_active_user from ..lib.babycode import babycode_to_html, BABYCODE_VERSION from ..constants import InfoboxKind from ..db import db -from ..models import Posts, PostHistory, Threads, Topics +from ..models import Posts, PostHistory, Threads, Topics, Mentions bp = Blueprint("posts", __name__, url_prefix = "/post") @@ -21,13 +21,22 @@ def create_post(thread_id, user_id, content, markup_language="babycode"): revision = PostHistory.create({ "post_id": post.id, - "content": parsed_content, + "content": parsed_content.result, "is_initial_revision": True, "original_markup": content, "markup_language": markup_language, "format_version": BABYCODE_VERSION, }) + for mention in parsed_content.mentions: + Mentions.create({ + 'revision_id': revision.id, + 'mentioned_user_id': mention['mentioned_user_id'], + 'original_mention_text': mention['mention_text'], + 'start_index': mention['start'], + 'end_index': mention['end'], + }) + post.update({"current_revision_id": revision.id}) return post @@ -38,13 +47,22 @@ def update_post(post_id, new_content, markup_language='babycode'): post = Posts.find({'id': post_id}) new_revision = PostHistory.create({ 'post_id': post.id, - 'content': parsed_content, + 'content': parsed_content.result, 'is_initial_revision': False, 'original_markup': new_content, 'markup_language': markup_language, 'format_version': BABYCODE_VERSION, }) + for mention in parsed_content.mentions: + Mentions.create({ + 'revision_id': new_revision.id, + 'mentioned_user_id': mention['mentioned_user_id'], + 'original_mention_text': mention['mention_text'], + 'start_index': mention['start'], + 'end_index': mention['end'], + }) + post.update({'current_revision_id': new_revision.id}) diff --git a/app/routes/threads.py b/app/routes/threads.py index 63c51e9..586ab25 100644 --- a/app/routes/threads.py +++ b/app/routes/threads.py @@ -48,7 +48,6 @@ def thread(slug): page = math.ceil((post_position) / POSTS_PER_PAGE) else: page = max(1, min(page_count, int(request.args.get("page", default = 1)))) - posts = thread.get_posts(POSTS_PER_PAGE, (page - 1) * POSTS_PER_PAGE) topic = Topics.find({"id": thread.topic_id}) other_topics = Topics.select() diff --git a/app/routes/users.py b/app/routes/users.py index cb802ee..f8fe4d0 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -4,7 +4,7 @@ from flask import ( from functools import wraps from ..db import db from ..lib.babycode import babycode_to_html, BABYCODE_VERSION -from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks, InviteKeys, BookmarkCollections, BookmarkedThreads +from ..models import Users, Sessions, Subscriptions, Avatars, PasswordResetLinks, InviteKeys, BookmarkCollections, BookmarkedThreads, Mentions, PostHistory from ..constants import InfoboxKind, PermissionLevel from ..auth import digest, verify from wand.image import Image @@ -90,6 +90,15 @@ def validate_username(username): return bool(re.fullmatch(pattern, username)) +def validate_display_name(display_name): + if not display_name: + return True + + pattern = r'^[\w!#$%^*\(\)\-_=+\[\]\{\}\|;:,.?\s]{3,50}$' + display_name = display_name.replace('@', '_') + return bool(re.fullmatch(pattern, display_name)) + + def redirect_if_logged_in(*args, **kwargs): def decorator(view_func): @wraps(view_func) @@ -184,7 +193,7 @@ def log_in(): @redirect_if_logged_in(".page", username = lambda: get_active_user().username) def log_in_post(): target_user = Users.find({ - "username": request.form['username'] + "username": request.form['username'].lower() }) if not target_user: flash("Incorrect username or password.", InfoboxKind.ERROR) @@ -239,7 +248,7 @@ def sign_up_post(): flash("Invalid username.", InfoboxKind.ERROR) return redirect(url_for("users.sign_up", key=key)) - user_exists = Users.count({"username": username}) > 0 + user_exists = Users.count({"username": username.lower()}) > 0 if user_exists: flash(f"Username '{username}' is already taken.", InfoboxKind.ERROR) return redirect(url_for("users.sign_up", key=key)) @@ -254,8 +263,14 @@ def sign_up_post(): hashed = digest(password) + if username.lower() != username: + display_name = username + else: + display_name = '' + new_user = Users.create({ "username": username, + 'display_name': display_name, "password_hash": hashed, "permission": PermissionLevel.GUEST.value, }) @@ -279,14 +294,14 @@ def sign_up_post(): @bp.get("/") def page(username): - target_user = Users.find({"username": username}) + target_user = Users.find({"username": username.lower()}) return render_template("users/user.html", target_user = target_user) @bp.get("//settings") @login_required def settings(username): - target_user = Users.find({'username': username}) + target_user = Users.find({'username': username.lower()}) if target_user.id != get_active_user().id: return redirect('.settings', username = get_active_user().username) @@ -311,10 +326,16 @@ def settings_form(username): status = request.form.get('status', default="")[:100] original_sig = request.form.get('signature', default='').strip() if original_sig: - rendered_sig = babycode_to_html(original_sig) + rendered_sig = babycode_to_html(original_sig).result else: rendered_sig = '' session['subscribe_by_default'] = request.form.get('subscribe_by_default', default='off') == 'on' + display_name = request.form.get('display_name', default='') + if not validate_display_name(display_name): + flash('Invalid display name.', InfoboxKind.ERROR) + return redirect('.settings', username=user.username) + + old_dn = user.display_name user.update({ 'status': status, @@ -322,7 +343,22 @@ def settings_form(username): 'signature_rendered': rendered_sig, 'signature_format_version': BABYCODE_VERSION, 'signature_markup_language': 'babycode', + 'display_name': display_name, }) + + if old_dn != display_name: + # re-parse mentions + q = """SELECT DISTINCT m.revision_id FROM mentions m + JOIN post_history ph ON m.revision_id = ph.id + JOIN posts p ON p.current_revision_id = ph.id + WHERE m.mentioned_user_id = ?""" + mentions = db.query(q, int(user.id)) + with db.transaction(): + for mention in mentions: + rev = PostHistory.find({'id': int(mention['revision_id'])}) + parsed_content = babycode_to_html(rev.original_markup).result + rev.update({'content': parsed_content}) + flash('Settings updated.', InfoboxKind.INFO) return redirect(url_for('.settings', username=user.username)) @@ -488,7 +524,7 @@ def guest_user(user_id): @login_required def inbox(username): user = get_active_user() - if username != user.username: + if username.lower() != user.username: return redirect(url_for(".inbox", username = user.username)) new_posts = [] @@ -630,7 +666,7 @@ def reset_link_login_form(key): @login_required def invite_links(username): target_user = Users.find({ - 'username': username + 'username': username.lower() }) if not target_user or not target_user.can_invite(): return redirect(url_for('.page', username=username)) @@ -649,10 +685,10 @@ def invite_links(username): @login_required def create_invite_link(username): target_user = Users.find({ - 'username': username + 'username': username.lower() }) if not target_user or not target_user.can_invite(): - return redirect(url_for('.page', username=username)) + return redirect(url_for('.page', username=username.lower())) if target_user.username != get_active_user().username: return redirect(url_for('.invite_links', username=target_user.username)) @@ -669,10 +705,10 @@ def create_invite_link(username): @login_required def revoke_invite_link(username): target_user = Users.find({ - 'username': username + 'username': username.lower() }) if not target_user or not target_user.can_invite(): - return redirect(url_for('.page', username=username)) + return redirect(url_for('.page', username=username.lower())) if target_user.username != get_active_user().username: return redirect(url_for('.invite_links', username=target_user.username)) @@ -695,7 +731,7 @@ def revoke_invite_link(username): @bp.get('//bookmarks') @login_required def bookmarks(username): - target_user = Users.find({'username': username}) + target_user = Users.find({'username': username.lower()}) if not target_user or target_user.username != get_active_user().username: return redirect(url_for('.bookmarks', username=get_active_user().username)) @@ -707,7 +743,7 @@ def bookmarks(username): @bp.get('//bookmarks/collections') @login_required def bookmark_collections(username): - target_user = Users.find({'username': username}) + target_user = Users.find({'username': username.lower()}) if not target_user or target_user.username != get_active_user().username: return redirect(url_for('.bookmark_collections', username=get_active_user().username)) diff --git a/app/schema.py b/app/schema.py index 6158477..ce58870 100644 --- a/app/schema.py +++ b/app/schema.py @@ -132,6 +132,15 @@ SCHEMA = [ "user_id" REFERENCES users(id) ON DELETE CASCADE )""", + """CREATE TABLE IF NOT EXISTS "mentions" ( + "id" INTEGER NOT NULL PRIMARY KEY, + "revision_id" REFERENCES post_history(id) ON DELETE CASCADE, + "mentioned_user_id" REFERENCES users(id) ON DELETE CASCADE, + "start_index" INTEGER NOT NULL, + "end_index" INTEGER NOT NULL, + "original_mention_text" TEXT NOT NULL + )""", + # INDEXES "CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)", "CREATE INDEX IF NOT EXISTS idx_posts_thread ON posts(thread_id, created_at, id)", @@ -155,6 +164,9 @@ SCHEMA = [ "CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_collection ON bookmarked_threads(collection_id)", "CREATE INDEX IF NOT EXISTS idx_bookmarked_threads_thread ON bookmarked_threads(thread_id)", + + "CREATE INDEX IF NOT EXISTS idx_mentioned_user ON mentions(mentioned_user_id)", + "CREATE INDEX IF NOT EXISTS idx_mention_revision_id ON mentions(revision_id)", ] def create(): diff --git a/app/templates/babycode.html b/app/templates/babycode.html index 8d72602..144aa59 100644 --- a/app/templates/babycode.html +++ b/app/templates/babycode.html @@ -128,7 +128,7 @@ [img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img] {{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }}

    -

    Text inside the tag becomes the alt text. The attribute is the image URL. The text inside the tag will become the image's alt text.

    +

    The attribute is the image URL. The text inside the tag will become the image's alt text.

    Images will always break up a paragraph and will get scaled down to a maximum of 400px. However, consecutive image tags will try to stay in one line, wrapping if necessary. Break the paragraph if you wish to keep images on their own paragraph.

    Multiple images attached to a post can be clicked to open a dialog to view them.

    @@ -157,13 +157,22 @@ {{ list | babycode | safe }}
    -

    Spoilers

    - {% set spoiler = "[spoiler=Major Metal Gear Spoilers]Snake dies[/spoiler]" %} -

    You can make a section collapsible by using the [spoiler] tag:

    - {{ ("[code]\n%s[/code]" % spoiler) | babycode | safe }} - Will produce: - {{ spoiler | babycode | safe }} - All other tags are supported inside spoilers. +

    Spoilers

    + {% set spoiler = "[spoiler=Major Metal Gear Spoilers]Snake dies[/spoiler]" %} +

    You can make a section collapsible by using the [spoiler] tag:

    + {{ ("[code]\n%s[/code]" % spoiler) | babycode | safe }} + Will produce: + {{ spoiler | babycode | safe }} + All other tags are supported inside spoilers. +
    +
    +

    Mentioning users

    +

    You can mention users by their username (not their display name) by using @username. A user's username is always shown below their avatar and display name on their posts and their user page.

    +

    A mention will show up on your post as a clickable box with the user's display name if they have one set or their username with an @ symbol if they don't:

    + @user-without-display-name + User with display name + Your display name +

    Mentioning a user does not notify them. It is simply a way to link to their profile in your posts.

    {% endset %} {{ sections | safe }} diff --git a/app/templates/common/macros.html b/app/templates/common/macros.html index 5ca6bf3..bc45a75 100644 --- a/app/templates/common/macros.html +++ b/app/templates/common/macros.html @@ -105,7 +105,7 @@
      {% for tag in banned_tags | unique %} -
    • [{{ tag }}]
    • +
    • {{ tag }}
    • {% endfor %}
    @@ -156,7 +156,8 @@ - {{ post['username'] }} + {{ post['display_name'] or post['username'] }} + @{{ post.username }} {% if post['status'] %} {{ post['status'] }} {% endif %} @@ -205,7 +206,7 @@ {% endif %} {% if show_reply %} - {% set qtext = "[url=%s]%s said:[/url]" | format(post_permalink, post['username']) %} + {% set qtext = "@%s [url=%s]said:[/url]" | format(post['username'], post_permalink) %} {% set reply_text = "%s\n[quote]\n%s\n[/quote]\n" | format(qtext, post['original_markup']) %} {% endif %} diff --git a/app/templates/common/topnav.html b/app/templates/common/topnav.html index fd1b761..80dbeec 100644 --- a/app/templates/common/topnav.html +++ b/app/templates/common/topnav.html @@ -11,7 +11,7 @@ {% endif %} {% else %} {% with user = get_active_user() %} - Welcome, {{user.username}} + Welcome, {{user.get_readable_name()}} {% endwith %} diff --git a/data/static/css/style.css b/data/static/css/style.css index 7e53ec2..3879743 100644 --- a/data/static/css/style.css +++ b/data/static/css/style.css @@ -1409,3 +1409,24 @@ footer { font-weight: bold; font-size: larger; } + +a.mention, a.mention:visited { + display: inline-block; + color: white; + background-color: rgb(135.1928346457, 145.0974015748, 123.0025984252); + padding: 5px; + border-radius: 4px; + text-decoration: none; +} +a.mention.display, a.mention:visited.display { + text-decoration: underline; + text-decoration-style: dashed; +} +a.mention.me, a.mention:visited.me { + background-color: rgb(123.0025984252, 145.0974015748, 143.9545669291); + border: 1px dashed; +} +a.mention:hover, a.mention:visited:hover { + background-color: rgb(229.84, 231.92, 227.28); + color: black; +} diff --git a/data/static/css/theme-otomotone.css b/data/static/css/theme-otomotone.css index f7e3aff..d0c837c 100644 --- a/data/static/css/theme-otomotone.css +++ b/data/static/css/theme-otomotone.css @@ -1410,6 +1410,27 @@ footer { font-size: larger; } +a.mention, a.mention:visited { + display: inline-block; + color: #e6e6e6; + background-color: rgb(96.95, 81.55, 96.95); + padding: 5px; + border-radius: 8px; + text-decoration: none; +} +a.mention.display, a.mention:visited.display { + text-decoration: underline; + text-decoration-style: dashed; +} +a.mention.me, a.mention:visited.me { + background-color: rgb(96.95, 89.25, 81.55); + border: 1px dashed; +} +a.mention:hover, a.mention:visited:hover { + background-color: #ae6bae; + color: #e6e6e6; +} + #topnav { margin-bottom: 10px; border: 10px solid rgb(40, 40, 40); diff --git a/data/static/css/theme-peachy.css b/data/static/css/theme-peachy.css index 0a84b4b..ec1c7c2 100644 --- a/data/static/css/theme-peachy.css +++ b/data/static/css/theme-peachy.css @@ -1410,6 +1410,27 @@ footer { font-size: larger; } +a.mention, a.mention:visited { + display: inline-block; + color: white; + background-color: rgb(155.8907865169, 93.2211235955, 76.5092134831); + padding: 3px; + border-radius: 16px; + text-decoration: none; +} +a.mention.display, a.mention:visited.display { + text-decoration: underline; + text-decoration-style: dashed; +} +a.mention.me, a.mention:visited.me { + background-color: rgb(99.4880898876, 155.8907865169, 76.5092134831); + border: 1px dashed; +} +a.mention:hover, a.mention:visited:hover { + background-color: rgb(231.56, 212.36, 207.24); + color: black; +} + #topnav { border-top-left-radius: 16px; border-top-right-radius: 16px; diff --git a/data/static/js/bitties/pyrom-bitty.js b/data/static/js/bitties/pyrom-bitty.js index 099f98b..285486e 100644 --- a/data/static/js/bitties/pyrom-bitty.js +++ b/data/static/js/bitties/pyrom-bitty.js @@ -1,5 +1,6 @@ const bookmarkMenuHrefTemplate = '/hyperapi/bookmarks-dropdown'; const previewEndpoint = '/api/babycode-preview'; +const userEndpoint = '/api/current-user'; const delay = ms => {return new Promise(resolve => setTimeout(resolve, ms))} @@ -260,4 +261,19 @@ export default class { el.textContent = date.toLocaleString(); } } + + #currentUsername = undefined; + async highlightMentions(ev, el) { + if (this.#currentUsername === undefined) { + const userInfo = await this.api.getJSON(userEndpoint); + if (!userInfo.value) { + return; + } + this.#currentUsername = userInfo.value.user.username; + } + + if (el.getString('username') === this.#currentUsername) { + el.classList.add('me'); + } + } } diff --git a/data/static/js/thread.js b/data/static/js/thread.js index 4570235..f283a6e 100644 --- a/data/static/js/thread.js +++ b/data/static/js/thread.js @@ -82,7 +82,6 @@ quoteButton.textContent = "Quote fragment" quoteButton.className = "reduced" quotePopover.appendChild(quoteButton); - document.body.appendChild(quotePopover); return quoteButton; } @@ -98,7 +97,7 @@ if (ta.value.trim() !== "") { ta.value += "\n" } - ta.value += `[url=${postPermalink}]${authorUsername} said:[/url]\n[quote]< :scissors: > ${document.getSelection().toString()} < :scissors: >[/quote]\n`; + ta.value += `@${authorUsername} [url=${postPermalink}]said:[/url]\n[quote]< :scissors: > ${document.getSelection().toString()} < :scissors: >[/quote]\n`; ta.scrollIntoView() ta.focus(); diff --git a/sass/_default.scss b/sass/_default.scss index 1aca31c..3cbd3e3 100644 --- a/sass/_default.scss +++ b/sass/_default.scss @@ -1369,3 +1369,36 @@ $motd_content_padding_right: 25% !default; font-weight: bold; font-size: larger; } + +$mention_font_color: $DEFAULT_FONT_COLOR_INVERSE !default; +$mention_font_color_hover: $DEFAULT_FONT_COLOR !default; +$mention_background_color: $DARK_2 !default; +$mention_background_color_me: color.adjust($DARK_2, $hue: 90) !default; +$mention_background_color_hover: $LIGHT_2 !default; +$mention_border_me: 1px dashed; +$mention_padding: $SMALL_PADDING !default; +$mention_border_radius: $DEFAULT_BORDER_RADIUS !default; +a.mention, a.mention:visited { + display: inline-block; + color: $mention_font_color; + background-color: $mention_background_color; + padding: $mention_padding; + border-radius: $mention_border_radius; + text-decoration: none; + + &.display { + text-decoration: underline; + text-decoration-style: dashed; + } + + &.me { + background-color: $mention_background_color_me; + border: $mention_border_me; + } + + &:hover { + background-color: $mention_background_color_hover; + color: $mention_font_color_hover; + } + +} diff --git a/sass/otomotone.scss b/sass/otomotone.scss index 7c3e6ab..2b54145 100644 --- a/sass/otomotone.scss +++ b/sass/otomotone.scss @@ -80,6 +80,8 @@ $br: 8px; $tab_button_active_color: #8a5584, $bookmarks_dropdown_background_color: $lightish_accent, + + $mention_font_color: $fc, ); #topnav {