add mentions

This commit is contained in:
2025-12-02 06:13:50 +03:00
parent 414298b4b4
commit 1d5d5a8c64
28 changed files with 366 additions and 64 deletions

View File

@@ -40,10 +40,11 @@ def create_admin():
def create_deleted_user(): def create_deleted_user():
username = "DeletedUser" username = "DeletedUser"
if Users.count({"username": username}) == 0: if Users.count({"username": username.lower()}) == 0:
print("Creating DeletedUser") print("Creating DeletedUser")
Users.create({ Users.create({
"username": username, "username": username.lower(),
"display_name": username,
"password_hash": "", "password_hash": "",
"permission": PermissionLevel.SYSTEM.value, "permission": PermissionLevel.SYSTEM.value,
}) })
@@ -61,7 +62,7 @@ def reparse_babycode():
with db.transaction(): with db.transaction():
for ph in post_histories: for ph in post_histories:
ph.update({ ph.update({
'content': babycode_to_html(ph['original_markup']), 'content': babycode_to_html(ph['original_markup']).result,
'format_version': BABYCODE_VERSION, 'format_version': BABYCODE_VERSION,
}) })
print('Re-parsing posts done.') print('Re-parsing posts done.')
@@ -76,7 +77,7 @@ def reparse_babycode():
with db.transaction(): with db.transaction():
for user in users_with_sigs: for user in users_with_sigs:
user.update({ 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, 'signature_format_version': BABYCODE_VERSION,
}) })
print(f'Re-parsed {len(users_with_sigs)} user sigs.') print(f'Re-parsed {len(users_with_sigs)} user sigs.')
@@ -90,7 +91,7 @@ def reparse_babycode():
with db.transaction(): with db.transaction():
for motd in stale_motds: for motd in stale_motds:
motd.update({ 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, 'format_version': BABYCODE_VERSION,
}) })
print('Re-parsing MOTDs done.') print('Re-parsing MOTDs done.')
@@ -206,7 +207,7 @@ def create_app():
@app.template_filter('babycode') @app.template_filter('babycode')
def babycode_filter(markup): def babycode_filter(markup):
return babycode_to_html(markup) return babycode_to_html(markup).result
@app.template_filter('extract_h2') @app.template_filter('extract_h2')
def extract_h2(content): def extract_h2(content):

View File

@@ -48,7 +48,7 @@ REACTION_EMOJI = [
] ]
MOTD_BANNED_TAGS = [ MOTD_BANNED_TAGS = [
'img', 'spoiler', 'img', 'spoiler', 'mention'
] ]
def permission_level_string(perm): def permission_level_string(perm):

View File

@@ -6,6 +6,16 @@ from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound as PygmentsClassNotFound from pygments.util import ClassNotFound as PygmentsClassNotFound
import re import re
class BabycodeParseResult:
def __init__(self, result, mentions=[]):
self.result = result
self.mentions = mentions
def __str__(self):
return self.result
BABYCODE_VERSION = 5 BABYCODE_VERSION = 5
NAMED_COLORS = [ NAMED_COLORS = [
@@ -78,6 +88,9 @@ def tag_list(children):
return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x]) return " ".join([f"<li>{x}</li>" for x in list_body.split("\1") if x])
def tag_color(children, attr, surrounding): 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})$" hex_re = r"^#?([0-9a-f]{6}|[0-9a-f]{3})$"
potential_color = attr.lower().strip() potential_color = attr.lower().strip()
@@ -206,6 +219,24 @@ def is_inline(e):
return e['type'] != 'rule' 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"<a class='mention{' display' if target_user.has_display_name() else ''}' href='{url_for('users.page', username=target_user.username)}' title='@{target_user.username}' data-init='highlightMentions' data-username='{target_user.username}'>{'@' if not target_user.has_display_name() else ''}{target_user.get_readable_name()}</a>"
def should_collapse(text, surrounding): def should_collapse(text, surrounding):
if not isinstance(text, str): if not isinstance(text, str):
return False return False
@@ -218,15 +249,19 @@ def should_collapse(text, surrounding):
return False return False
def babycode_to_html(s, banned_tags=None): def sanitize(s):
allowed_tags = list(TAGS.keys()) 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: if banned_tags is not None:
for tag in banned_tags: for tag in banned_tags:
allowed_tags.remove(tag) allowed_tags.discard(tag)
subj = escape(s.strip().replace('\r\n', '\n').replace('\r', '\n')) subj = sanitize(s)
parser = Parser(subj) parser = Parser(subj)
parser.valid_bbcode_tags = allowed_tags parser.valid_bbcode_tags = allowed_tags
parser.bbcode_tags_only_text_children = TEXT_ONLY parser.bbcode_tags_only_text_children = TEXT_ONLY
parser.mentions_allowed = '@mention' not in banned_tags
parser.valid_emotes = EMOJI.keys() parser.valid_emotes = EMOJI.keys()
uncollapsed = parser.parse() uncollapsed = parser.parse()
@@ -241,6 +276,7 @@ def babycode_to_html(s, banned_tags=None):
elements.append(e) elements.append(e)
out = "" out = ""
mentions = []
def fold(element, nobr, surrounding): def fold(element, nobr, surrounding):
if isinstance(element, str): if isinstance(element, str):
if nobr: if nobr:
@@ -266,6 +302,8 @@ def babycode_to_html(s, banned_tags=None):
return EMOJI[element['name']] return EMOJI[element['name']]
case "rule": case "rule":
return "<hr>" return "<hr>"
case "mention":
return make_mention(element, mentions)
for i in range(len(elements)): for i in range(len(elements)):
e = elements[i] 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 elements[i + 1] if i+1 < len(elements) else None
) )
out = out + fold(e, False, surrounding) out = out + fold(e, False, surrounding)
return out return BabycodeParseResult(out, mentions)

View File

@@ -6,12 +6,14 @@ PAT_EMOTE = r"[^\s:]"
PAT_BBCODE_TAG = r"\w" PAT_BBCODE_TAG = r"\w"
PAT_BBCODE_ATTR = r"[^\]]" PAT_BBCODE_ATTR = r"[^\]]"
PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]" PAT_LINK = r"https?:\/\/[\w\-_.?:\/=&~@#%]+[\w\-\/]"
PAT_MENTION = r'[a-zA-Z0-9_-]'
class Parser: class Parser:
def __init__(self, src_str): def __init__(self, src_str):
self.valid_bbcode_tags = [] self.valid_bbcode_tags = {}
self.valid_emotes = [] self.valid_emotes = []
self.bbcode_tags_only_text_children = [], self.bbcode_tags_only_text_children = []
self.mentions_allowed = True
self.source = src_str self.source = src_str
self.position = 0 self.position = 0
self.position_stack = [] self.position_stack = []
@@ -206,6 +208,25 @@ class Parser:
"url": word "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): def parse_element(self, siblings):
if self.is_end_of_source(): if self.is_end_of_source():
@@ -214,7 +235,8 @@ class Parser:
element = self.parse_emote() \ element = self.parse_emote() \
or self.parse_bbcode() \ or self.parse_bbcode() \
or self.parse_rule() \ or self.parse_rule() \
or self.parse_link() or self.parse_link() \
or self.parse_mention()
if element is None: if element is None:
if len(siblings) > 0: if len(siblings) > 0:

View File

@@ -22,6 +22,18 @@ def create_default_bookmark_collections():
for user in user_ids_without_default_collection: for user in user_ids_without_default_collection:
BookmarkCollections.create_default(user['id']) 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] # format: [str|tuple(str, any...)|callable]
MIGRATIONS = [ MIGRATIONS = [
migrate_old_avatars, migrate_old_avatars,
@@ -30,6 +42,7 @@ MIGRATIONS = [
'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL', 'ALTER TABLE "post_history" ADD COLUMN "format_version" INTEGER DEFAULT NULL',
add_signature_format, add_signature_format,
create_default_bookmark_collections, create_default_bookmark_collections,
add_display_name,
] ]
def run_migrations(): def run_migrations():

View File

@@ -53,7 +53,8 @@ class Users(Model):
COUNT(DISTINCT threads.id) AS thread_count, COUNT(DISTINCT threads.id) AS thread_count,
MAX(threads.title) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_title, 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, 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 FROM users
LEFT JOIN posts ON posts.user_id = users.id LEFT JOIN posts ON posts.user_id = users.id
LEFT JOIN threads ON threads.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) res = db.query(q, self.id)
return [BookmarkCollections.find({'id': bc['id']}) for bc in res] 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): class Topics(Model):
table = "topics" table = "topics"
@@ -116,6 +126,7 @@ class Topics(Model):
SELECT SELECT
topics.id, topics.name, topics.slug, topics.description, topics.is_locked, topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
users.username AS latest_thread_username, users.username AS latest_thread_username,
users.display_name AS latest_thread_display_name,
threads.title AS latest_thread_title, threads.title AS latest_thread_title,
threads.slug AS latest_thread_slug, threads.slug AS latest_thread_slug,
threads.created_at AS latest_thread_created_at threads.created_at AS latest_thread_created_at
@@ -141,7 +152,7 @@ class Topics(Model):
SELECT SELECT
threads.topic_id, threads.id AS thread_id, threads.title AS thread_title, threads.slug AS thread_slug, 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, 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 ROW_NUMBER() OVER (PARTITION BY threads.topic_id ORDER BY posts.created_at DESC) AS rn
FROM FROM
threads threads
@@ -154,7 +165,7 @@ class Topics(Model):
topic_id, topic_id,
thread_id, thread_title, thread_slug, thread_id, thread_title, thread_slug,
post_id, post_created_at, post_id, post_created_at,
username username, display_name
FROM FROM
ranked_threads ranked_threads
WHERE WHERE
@@ -170,6 +181,7 @@ class Topics(Model):
'thread_slug': thread['thread_slug'], 'thread_slug': thread['thread_slug'],
'post_id': thread['post_id'], 'post_id': thread['post_id'],
'username': thread['username'], 'username': thread['username'],
'display_name': thread['display_name'],
'post_created_at': thread['post_created_at'] 'post_created_at': thread['post_created_at']
} }
return active_threads return active_threads
@@ -185,7 +197,9 @@ class Topics(Model):
SELECT SELECT
threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied, threads.id, threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
users.username AS started_by, users.username AS started_by,
users.display_name AS started_by_display_name,
u.username AS latest_post_username, u.username AS latest_post_username,
u.display_name AS latest_post_display_name,
ph.content AS latest_post_content, ph.content AS latest_post_content,
posts.created_at AS latest_post_created_at, posts.created_at AS latest_post_created_at,
posts.id AS latest_post_id posts.id AS latest_post_id
@@ -230,7 +244,13 @@ class Threads(Model):
class Posts(Model): class Posts(Model):
FULL_POSTS_QUERY = """ FULL_POSTS_QUERY = """
SELECT 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 FROM
posts posts
JOIN JOIN
@@ -410,3 +430,7 @@ class MOTD(Model):
q = 'SELECT id FROM motd' q = 'SELECT id FROM motd'
res = db.query(q) res = db.query(q)
return [MOTD.find({'id': i['id']}) for i in res] return [MOTD.find({'id': i['id']}) for i in res]
class Mentions(Model):
table = 'mentions'

View File

@@ -41,7 +41,7 @@ def babycode_preview():
if not markup or not isinstance(markup, str): if not markup or not isinstance(markup, str):
return {'error': 'markup field missing or invalid type'}, 400 return {'error': 'markup field missing or invalid type'}, 400
banned_tags = request.json.get('banned_tags', []) 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} return {'html': rendered}
@@ -213,3 +213,16 @@ def bookmark_thread(thread_id):
return {'error': 'bad request'}, 400 return {'error': 'bad request'}, 400
return {'status': 'ok'}, 200 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,
}
}

View File

@@ -73,7 +73,7 @@ def motd_editor_form():
data = { data = {
'title': title, 'title': title,
'body_original_markup': orig_body, '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, 'format_version': BABYCODE_VERSION,
'edited_at': int(time.time()), 'edited_at': int(time.time()),
} }

View File

@@ -5,7 +5,7 @@ from .users import login_required, get_active_user
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION from ..lib.babycode import babycode_to_html, BABYCODE_VERSION
from ..constants import InfoboxKind from ..constants import InfoboxKind
from ..db import db 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") 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({ revision = PostHistory.create({
"post_id": post.id, "post_id": post.id,
"content": parsed_content, "content": parsed_content.result,
"is_initial_revision": True, "is_initial_revision": True,
"original_markup": content, "original_markup": content,
"markup_language": markup_language, "markup_language": markup_language,
"format_version": BABYCODE_VERSION, "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}) post.update({"current_revision_id": revision.id})
return post return post
@@ -38,13 +47,22 @@ def update_post(post_id, new_content, markup_language='babycode'):
post = Posts.find({'id': post_id}) post = Posts.find({'id': post_id})
new_revision = PostHistory.create({ new_revision = PostHistory.create({
'post_id': post.id, 'post_id': post.id,
'content': parsed_content, 'content': parsed_content.result,
'is_initial_revision': False, 'is_initial_revision': False,
'original_markup': new_content, 'original_markup': new_content,
'markup_language': markup_language, 'markup_language': markup_language,
'format_version': BABYCODE_VERSION, '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}) post.update({'current_revision_id': new_revision.id})

View File

@@ -48,7 +48,6 @@ def thread(slug):
page = math.ceil((post_position) / POSTS_PER_PAGE) page = math.ceil((post_position) / POSTS_PER_PAGE)
else: else:
page = max(1, min(page_count, int(request.args.get("page", default = 1)))) 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) posts = thread.get_posts(POSTS_PER_PAGE, (page - 1) * POSTS_PER_PAGE)
topic = Topics.find({"id": thread.topic_id}) topic = Topics.find({"id": thread.topic_id})
other_topics = Topics.select() other_topics = Topics.select()

View File

@@ -4,7 +4,7 @@ from flask import (
from functools import wraps from functools import wraps
from ..db import db from ..db import db
from ..lib.babycode import babycode_to_html, BABYCODE_VERSION 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 ..constants import InfoboxKind, PermissionLevel
from ..auth import digest, verify from ..auth import digest, verify
from wand.image import Image from wand.image import Image
@@ -90,6 +90,15 @@ def validate_username(username):
return bool(re.fullmatch(pattern, 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 redirect_if_logged_in(*args, **kwargs):
def decorator(view_func): def decorator(view_func):
@wraps(view_func) @wraps(view_func)
@@ -184,7 +193,7 @@ def log_in():
@redirect_if_logged_in(".page", username = lambda: get_active_user().username) @redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def log_in_post(): def log_in_post():
target_user = Users.find({ target_user = Users.find({
"username": request.form['username'] "username": request.form['username'].lower()
}) })
if not target_user: if not target_user:
flash("Incorrect username or password.", InfoboxKind.ERROR) flash("Incorrect username or password.", InfoboxKind.ERROR)
@@ -239,7 +248,7 @@ def sign_up_post():
flash("Invalid username.", InfoboxKind.ERROR) flash("Invalid username.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up", key=key)) 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: if user_exists:
flash(f"Username '{username}' is already taken.", InfoboxKind.ERROR) flash(f"Username '{username}' is already taken.", InfoboxKind.ERROR)
return redirect(url_for("users.sign_up", key=key)) return redirect(url_for("users.sign_up", key=key))
@@ -254,8 +263,14 @@ def sign_up_post():
hashed = digest(password) hashed = digest(password)
if username.lower() != username:
display_name = username
else:
display_name = ''
new_user = Users.create({ new_user = Users.create({
"username": username, "username": username,
'display_name': display_name,
"password_hash": hashed, "password_hash": hashed,
"permission": PermissionLevel.GUEST.value, "permission": PermissionLevel.GUEST.value,
}) })
@@ -279,14 +294,14 @@ def sign_up_post():
@bp.get("/<username>") @bp.get("/<username>")
def page(username): 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) return render_template("users/user.html", target_user = target_user)
@bp.get("/<username>/settings") @bp.get("/<username>/settings")
@login_required @login_required
def settings(username): def settings(username):
target_user = Users.find({'username': username}) target_user = Users.find({'username': username.lower()})
if target_user.id != get_active_user().id: if target_user.id != get_active_user().id:
return redirect('.settings', username = get_active_user().username) return redirect('.settings', username = get_active_user().username)
@@ -311,10 +326,16 @@ def settings_form(username):
status = request.form.get('status', default="")[:100] status = request.form.get('status', default="")[:100]
original_sig = request.form.get('signature', default='').strip() original_sig = request.form.get('signature', default='').strip()
if original_sig: if original_sig:
rendered_sig = babycode_to_html(original_sig) rendered_sig = babycode_to_html(original_sig).result
else: else:
rendered_sig = '' rendered_sig = ''
session['subscribe_by_default'] = request.form.get('subscribe_by_default', default='off') == 'on' 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({ user.update({
'status': status, 'status': status,
@@ -322,7 +343,22 @@ def settings_form(username):
'signature_rendered': rendered_sig, 'signature_rendered': rendered_sig,
'signature_format_version': BABYCODE_VERSION, 'signature_format_version': BABYCODE_VERSION,
'signature_markup_language': 'babycode', '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) flash('Settings updated.', InfoboxKind.INFO)
return redirect(url_for('.settings', username=user.username)) return redirect(url_for('.settings', username=user.username))
@@ -488,7 +524,7 @@ def guest_user(user_id):
@login_required @login_required
def inbox(username): def inbox(username):
user = get_active_user() user = get_active_user()
if username != user.username: if username.lower() != user.username:
return redirect(url_for(".inbox", username = user.username)) return redirect(url_for(".inbox", username = user.username))
new_posts = [] new_posts = []
@@ -630,7 +666,7 @@ def reset_link_login_form(key):
@login_required @login_required
def invite_links(username): def invite_links(username):
target_user = Users.find({ target_user = Users.find({
'username': username 'username': username.lower()
}) })
if not target_user or not target_user.can_invite(): if not target_user or not target_user.can_invite():
return redirect(url_for('.page', username=username)) return redirect(url_for('.page', username=username))
@@ -649,10 +685,10 @@ def invite_links(username):
@login_required @login_required
def create_invite_link(username): def create_invite_link(username):
target_user = Users.find({ target_user = Users.find({
'username': username 'username': username.lower()
}) })
if not target_user or not target_user.can_invite(): 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: if target_user.username != get_active_user().username:
return redirect(url_for('.invite_links', username=target_user.username)) return redirect(url_for('.invite_links', username=target_user.username))
@@ -669,10 +705,10 @@ def create_invite_link(username):
@login_required @login_required
def revoke_invite_link(username): def revoke_invite_link(username):
target_user = Users.find({ target_user = Users.find({
'username': username 'username': username.lower()
}) })
if not target_user or not target_user.can_invite(): 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: if target_user.username != get_active_user().username:
return redirect(url_for('.invite_links', username=target_user.username)) return redirect(url_for('.invite_links', username=target_user.username))
@@ -695,7 +731,7 @@ def revoke_invite_link(username):
@bp.get('/<username>/bookmarks') @bp.get('/<username>/bookmarks')
@login_required @login_required
def bookmarks(username): 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: if not target_user or target_user.username != get_active_user().username:
return redirect(url_for('.bookmarks', username=get_active_user().username)) return redirect(url_for('.bookmarks', username=get_active_user().username))
@@ -707,7 +743,7 @@ def bookmarks(username):
@bp.get('/<username>/bookmarks/collections') @bp.get('/<username>/bookmarks/collections')
@login_required @login_required
def bookmark_collections(username): 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: if not target_user or target_user.username != get_active_user().username:
return redirect(url_for('.bookmark_collections', username=get_active_user().username)) return redirect(url_for('.bookmark_collections', username=get_active_user().username))

View File

@@ -132,6 +132,15 @@ SCHEMA = [
"user_id" REFERENCES users(id) ON DELETE CASCADE "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 # INDEXES
"CREATE INDEX IF NOT EXISTS idx_post_history_post_id ON post_history(post_id)", "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)", "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_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_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(): def create():

View File

@@ -128,7 +128,7 @@
<code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img]</code> <code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Python logo with a cowboy hat[/img]</code>
{{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }} {{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }}
</p> </p>
<p>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.</p> <p>The attribute is the image URL. The text inside the tag will become the image's alt text.</p>
<p>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.</p> <p>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.</p>
<p>Multiple images attached to a post can be clicked to open a dialog to view them.</p> <p>Multiple images attached to a post can be clicked to open a dialog to view them.</p>
</section> </section>
@@ -165,6 +165,15 @@
{{ spoiler | babycode | safe }} {{ spoiler | babycode | safe }}
All other tags are supported inside spoilers. All other tags are supported inside spoilers.
</section> </section>
<section class="babycode-guide-section">
<h2 id="mentions">Mentioning users</h2>
<p>You can mention users by their username (<em>not</em> their display name) by using <code class="inline-code">@username</code>. A user's username is always shown below their avatar and display name on their posts and their user page.</p>
<p>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 <code class="inline-code">@</code> symbol if they don't:</p>
<a class="mention" href="#mentions" title="@user-without-display-name">@user-without-display-name</a>
<a class="mention display" href="#mentions" title="@user-with-display-name">User with display name</a>
<a class="mention display me" href="#mentions" title="@your-username">Your display name</a>
<p>Mentioning a user does not notify them. It is simply a way to link to their profile in your posts.</p>
</section>
{% endset %} {% endset %}
{{ sections | safe }} {{ sections | safe }}
</div> </div>

View File

@@ -105,7 +105,7 @@
<div> <div>
<ul class="horizontal"> <ul class="horizontal">
{% for tag in banned_tags | unique %} {% for tag in banned_tags | unique %}
<li><code class="inline-code">[{{ tag }}]</code></li> <li><code class="inline-code">{{ tag }}</code></li>
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
@@ -156,7 +156,8 @@
<a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;"> <a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;">
<img src="{{ post['avatar_path'] }}" class="avatar"> <img src="{{ post['avatar_path'] }}" class="avatar">
</a> </a>
<a href="{{ url_for("users.page", username=post['username']) }}" class="username-link">{{ post['username'] }}</a> <a href="{{ url_for("users.page", username=post['username']) }}" class="username-link">{{ post['display_name'] or post['username'] }}</a>
<em><abbr title="Mention">@{{ post.username }}</abbr></em>
{% if post['status'] %} {% if post['status'] %}
<em class="user-status">{{ post['status'] }}</em> <em class="user-status">{{ post['status'] }}</em>
{% endif %} {% endif %}
@@ -205,7 +206,7 @@
{% endif %} {% endif %}
{% if show_reply %} {% 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']) %} {% set reply_text = "%s\n[quote]\n%s\n[/quote]\n" | format(qtext, post['original_markup']) %}
<button data-send="addQuote" value="{{ reply_text }}" class="reply-button">Quote</button> <button data-send="addQuote" value="{{ reply_text }}" class="reply-button">Quote</button>
{% endif %} {% endif %}

View File

@@ -11,7 +11,7 @@
{% endif %} {% endif %}
{% else %} {% else %}
{% with user = get_active_user() %} {% with user = get_active_user() %}
Welcome, <a href="{{ url_for("users.page", username = user.username) }}">{{user.username}}</a> Welcome, <a href="{{ url_for("users.page", username = user.username) }}">{{user.get_readable_name()}}</a>
<ul class="horizontal"> <ul class="horizontal">
<li><a href="{{ url_for("users.settings", username = user.username) }}">Settings</a></li> <li><a href="{{ url_for("users.settings", username = user.username) }}">Settings</a></li>
<li><a href="{{ url_for("users.inbox", username = user.username) }}">Inbox</a></li> <li><a href="{{ url_for("users.inbox", username = user.username) }}">Inbox</a></li>

View File

@@ -9,7 +9,7 @@
<label for="title">Title</label> <label for="title">Title</label>
<input name="title" id="title" type="text" required autocomplete="off" placeholder="Required" value="{{ current.title }}"><br> <input name="title" id="title" type="text" required autocomplete="off" placeholder="Required" value="{{ current.title }}"><br>
<label for="body">Body</label> <label for="body">Body</label>
{{ babycode_editor_component('body', ta_placeholder='MOTD body (required)', banned_tags=['img', 'spoiler'], prefill=current.body_original_markup) }} {{ babycode_editor_component('body', ta_placeholder='MOTD body (required)', banned_tags=['img', 'spoiler', '@mention'], prefill=current.body_original_markup) }}
<input type="submit" value="Save"> <input type="submit" value="Save">
<input class="critical" type="submit" formaction="{{ url_for('mod.motd_delete') }}" value="Delete MOTD" formnovalidate {{"disabled" if not current else ""}}> <input class="critical" type="submit" formaction="{{ url_for('mod.motd_delete') }}" value="Delete MOTD" formnovalidate {{"disabled" if not current else ""}}>
</form> </form>

View File

@@ -56,12 +56,12 @@
</span> </span>
&bullet; &bullet;
<span> <span>
Started by <a href="{{ url_for("users.page", username=thread['started_by']) }}">{{ thread['started_by'] }}</a> on {{ timestamp(thread['created_at']) }} Started by <a href="{{ url_for("users.page", username=thread['started_by']) }}">{{ thread['started_by_display_name'] or thread['started_by'] }}</a> on {{ timestamp(thread['created_at']) }}
</span> </span>
</span> </span>
</span> </span>
<span> <span>
Latest post by <a href="{{ url_for("users.page", username=thread['latest_post_username']) }}">{{ thread['latest_post_username'] }}</a> Latest post by <a href="{{ url_for("users.page", username=thread['latest_post_username']) }}">{{ thread['latest_post_display_name'] or thread['latest_post_username'] }}</a>
on <a href="{{ url_for("threads.thread", slug=thread['slug'], after=thread['latest_post_id']) }}">on {{ timestamp(thread['latest_post_created_at']) }}</a>: on <a href="{{ url_for("threads.thread", slug=thread['slug'], after=thread['latest_post_id']) }}">on {{ timestamp(thread['latest_post_created_at']) }}</a>:
</span> </span>
<span class="thread-info-post-preview"> <span class="thread-info-post-preview">

View File

@@ -26,12 +26,12 @@
{{ topic['description'] }} {{ topic['description'] }}
{% if topic['latest_thread_username'] %} {% if topic['latest_thread_username'] %}
<span> <span>
Latest thread: <a href="{{ url_for("threads.thread", slug=topic['latest_thread_slug'])}}">{{topic['latest_thread_title']}}</a> by <a href="{{url_for("users.page", username=topic['latest_thread_username'])}}">{{topic['latest_thread_username']}}</a> on {{ timestamp(topic['latest_thread_created_at']) }} Latest thread: <a href="{{ url_for("threads.thread", slug=topic['latest_thread_slug'])}}">{{topic['latest_thread_title']}}</a> by <a href="{{url_for("users.page", username=topic['latest_thread_username'])}}">{{topic['latest_thread_display_name'] or topic['latest_thread_username']}}</a> on {{ timestamp(topic['latest_thread_created_at']) }}
</span> </span>
{% if topic['id'] in active_threads %} {% if topic['id'] in active_threads %}
{% with thread=active_threads[topic['id']] %} {% with thread=active_threads[topic['id']] %}
<span> <span>
Latest post in: <a href="{{ url_for("threads.thread", slug=thread['thread_slug'])}}">{{ thread['thread_title'] }}</a> by <a href="{{ url_for("users.page", username=thread['username'])}}">{{ thread['username'] }}</a> at <a href="{{ get_post_url(thread.post_id, _anchor=true) }}">{{ timestamp(thread['post_created_at']) }}</a> Latest post in: <a href="{{ url_for("threads.thread", slug=thread['thread_slug'])}}">{{ thread['thread_title'] }}</a> by <a href="{{ url_for("users.page", username=thread['username'])}}">{{ thread['display_name'] or thread['username'] }}</a> at <a href="{{ get_post_url(thread.post_id, _anchor=true) }}">{{ timestamp(thread['post_created_at']) }}</a>
</span> </span>
{% endwith %} {% endwith %}
{% endif %} {% endif %}

View File

@@ -26,6 +26,8 @@
<option value='activity' {{ 'selected' if session['sort_by'] == 'activity' else '' }}>Latest activity</option> <option value='activity' {{ 'selected' if session['sort_by'] == 'activity' else '' }}>Latest activity</option>
<option value='thread' {{ 'selected' if session['sort_by'] == 'thread' else '' }}>Thread creation date</option> <option value='thread' {{ 'selected' if session['sort_by'] == 'thread' else '' }}>Thread creation date</option>
</select> </select>
<label for='display_name'>Display name</label>
<input type='text' id='display_name' name='display_name' value='{{ active_user.display_name }}' pattern="(?:[\w!#$%^*\(\)\-_=+\[\]\{\}\|;:,.?\s]{3,50})?" title='3-50 characters, no @, no <>, no &' placeholder='Optional. Will be shown in place of username.' autocomplete='off'></input>
<label for='status'>Status</label> <label for='status'>Status</label>
<input type='text' id='status' name='status' value='{{ active_user.status }}' maxlength=100 placeholder='Will be shown under your name. Max 100 characters.'> <input type='text' id='status' name='status' value='{{ active_user.status }}' maxlength=100 placeholder='Will be shown under your name. Max 100 characters.'>
<label for='babycode-content'>Signature</label> <label for='babycode-content'>Signature</label>

View File

@@ -4,7 +4,7 @@
<div class="darkbg login-container"> <div class="darkbg login-container">
<h1>Sign up</h1> <h1>Sign up</h1>
{% if inviter %} {% if inviter %}
<p>You have been invited by <a href="{{ url_for('users.page', username=inviter.username) }}">{{ inviter.username }}</a> to join {{ config.SITE_NAME }}. Create an identity below.</p> <p>You have been invited by <a href="{{ url_for('users.page', username=inviter.username) }}">{{ inviter.get_readable_name() }}</a> to join {{ config.SITE_NAME }}. Create an identity below.</p>
{% endif %} {% endif %}
<form method="post"> <form method="post">
{% if key %} {% if key %}

View File

@@ -1,9 +1,9 @@
{% from 'common/macros.html' import timestamp %} {% from 'common/macros.html' import timestamp %}
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}{{ target_user.username }}'s profile{% endblock %} {% block title %}{{ target_user.get_readable_name() }}'s profile{% endblock %}
{% block content %} {% block content %}
<div class="darkbg"> <div class="darkbg">
<h1 class="thread-title"><i>{{ target_user.username }}</i>'s profile</h1> <h1 class="thread-title"><i>{{ target_user.get_readable_name() }}</i>'s profile</h1>
{% if active_user.id == target_user.id %} {% if active_user.id == target_user.id %}
<div class="user-actions"> <div class="user-actions">
<a class="linkbutton" href="{{ url_for("users.settings", username = active_user.username) }}">Settings</a> <a class="linkbutton" href="{{ url_for("users.settings", username = active_user.username) }}">Settings</a>
@@ -45,7 +45,8 @@
<div class="user-page-usercard"> <div class="user-page-usercard">
<div class="usercard-inner"> <div class="usercard-inner">
<img class="avatar" src="{{ target_user.get_avatar_url() }}"> <img class="avatar" src="{{ target_user.get_avatar_url() }}">
<strong class="big">{{ target_user.username }}</strong> <strong class="big">{{ target_user.get_readable_name() }}</strong>
<em><abbr title="Mention">@{{ target_user.username }}</abbr></em>
{% if target_user.status %} {% if target_user.status %}
<em class="user-status">{{ target_user.status }}</em> <em class="user-status">{{ target_user.status }}</em>
{% endif %} {% endif %}
@@ -65,7 +66,7 @@
<li>Latest started thread: <a href="{{ url_for("threads.thread", slug = stats.latest_thread_slug) }}">{{ stats.latest_thread_title }}</a> <li>Latest started thread: <a href="{{ url_for("threads.thread", slug = stats.latest_thread_slug) }}">{{ stats.latest_thread_title }}</a>
{% endif %} {% endif %}
{% if stats.inviter_username %} {% if stats.inviter_username %}
<li>Invited by <a href="{{ url_for('users.page', username=stats.inviter_username) }}">{{ stats.inviter_username }}</a></li> <li>Invited by <a href="{{ url_for('users.page', username=stats.inviter_username) }}">{{ stats.inviter_display_name or stats.inviter_username }}</a></li>
{% endif %} {% endif %}
</ul> </ul>
{% endwith %} {% endwith %}

View File

@@ -1409,3 +1409,24 @@ footer {
font-weight: bold; font-weight: bold;
font-size: larger; 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;
}

View File

@@ -1410,6 +1410,27 @@ footer {
font-size: larger; 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 { #topnav {
margin-bottom: 10px; margin-bottom: 10px;
border: 10px solid rgb(40, 40, 40); border: 10px solid rgb(40, 40, 40);

View File

@@ -1410,6 +1410,27 @@ footer {
font-size: larger; 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 { #topnav {
border-top-left-radius: 16px; border-top-left-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;

View File

@@ -1,5 +1,6 @@
const bookmarkMenuHrefTemplate = '/hyperapi/bookmarks-dropdown'; const bookmarkMenuHrefTemplate = '/hyperapi/bookmarks-dropdown';
const previewEndpoint = '/api/babycode-preview'; const previewEndpoint = '/api/babycode-preview';
const userEndpoint = '/api/current-user';
const delay = ms => {return new Promise(resolve => setTimeout(resolve, ms))} const delay = ms => {return new Promise(resolve => setTimeout(resolve, ms))}
@@ -260,4 +261,19 @@ export default class {
el.textContent = date.toLocaleString(); 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');
}
}
} }

View File

@@ -82,7 +82,6 @@
quoteButton.textContent = "Quote fragment" quoteButton.textContent = "Quote fragment"
quoteButton.className = "reduced" quoteButton.className = "reduced"
quotePopover.appendChild(quoteButton); quotePopover.appendChild(quoteButton);
document.body.appendChild(quotePopover); document.body.appendChild(quotePopover);
return quoteButton; return quoteButton;
} }
@@ -98,7 +97,7 @@
if (ta.value.trim() !== "") { if (ta.value.trim() !== "") {
ta.value += "\n" 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.scrollIntoView()
ta.focus(); ta.focus();

View File

@@ -1369,3 +1369,36 @@ $motd_content_padding_right: 25% !default;
font-weight: bold; font-weight: bold;
font-size: larger; 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;
}
}

View File

@@ -80,6 +80,8 @@ $br: 8px;
$tab_button_active_color: #8a5584, $tab_button_active_color: #8a5584,
$bookmarks_dropdown_background_color: $lightish_accent, $bookmarks_dropdown_background_color: $lightish_accent,
$mention_font_color: $fc,
); );
#topnav { #topnav {