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():
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):

View File

@@ -48,7 +48,7 @@ REACTION_EMOJI = [
]
MOTD_BANNED_TAGS = [
'img', 'spoiler',
'img', 'spoiler', 'mention'
]
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
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"<li>{x}</li>" 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"<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):
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 "<hr>"
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)

View File

@@ -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:

View File

@@ -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():

View File

@@ -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'

View File

@@ -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,
}
}

View File

@@ -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()),
}

View File

@@ -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})

View File

@@ -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()

View File

@@ -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("/<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)
@bp.get("/<username>/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('/<username>/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('/<username>/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))

View File

@@ -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():

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>
{{ '[img=/static/avatars/default.webp]the Python logo with a cowboy hat[/img]' | babycode | safe }}
</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>Multiple images attached to a post can be clicked to open a dialog to view them.</p>
</section>
@@ -157,13 +157,22 @@
{{ list | babycode | safe }}
</section>
<section class="babycode-guide-section">
<h2 id="spoilers">Spoilers</h2>
{% set spoiler = "[spoiler=Major Metal Gear Spoilers]Snake dies[/spoiler]" %}
<p>You can make a section collapsible by using the <code class="inline-code">[spoiler]</code> tag:</p>
{{ ("[code]\n%s[/code]" % spoiler) | babycode | safe }}
Will produce:
{{ spoiler | babycode | safe }}
All other tags are supported inside spoilers.
<h2 id="spoilers">Spoilers</h2>
{% set spoiler = "[spoiler=Major Metal Gear Spoilers]Snake dies[/spoiler]" %}
<p>You can make a section collapsible by using the <code class="inline-code">[spoiler]</code> tag:</p>
{{ ("[code]\n%s[/code]" % spoiler) | babycode | safe }}
Will produce:
{{ spoiler | babycode | safe }}
All other tags are supported inside spoilers.
</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 %}
{{ sections | safe }}

View File

@@ -105,7 +105,7 @@
<div>
<ul class="horizontal">
{% for tag in banned_tags | unique %}
<li><code class="inline-code">[{{ tag }}]</code></li>
<li><code class="inline-code">{{ tag }}</code></li>
{% endfor %}
</ul>
</div>
@@ -156,7 +156,8 @@
<a href="{{ url_for("users.page", username=post['username']) }}" style="display: contents;">
<img src="{{ post['avatar_path'] }}" class="avatar">
</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'] %}
<em class="user-status">{{ post['status'] }}</em>
{% 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']) %}
<button data-send="addQuote" value="{{ reply_text }}" class="reply-button">Quote</button>
{% endif %}

View File

@@ -11,7 +11,7 @@
{% endif %}
{% else %}
{% 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">
<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>

View File

@@ -9,7 +9,7 @@
<label for="title">Title</label>
<input name="title" id="title" type="text" required autocomplete="off" placeholder="Required" value="{{ current.title }}"><br>
<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 class="critical" type="submit" formaction="{{ url_for('mod.motd_delete') }}" value="Delete MOTD" formnovalidate {{"disabled" if not current else ""}}>
</form>

View File

@@ -56,12 +56,12 @@
</span>
&bullet;
<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>
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>:
</span>
<span class="thread-info-post-preview">

View File

@@ -26,12 +26,12 @@
{{ topic['description'] }}
{% if topic['latest_thread_username'] %}
<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>
{% if topic['id'] in active_threads %}
{% with thread=active_threads[topic['id']] %}
<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>
{% endwith %}
{% endif %}

View File

@@ -26,6 +26,8 @@
<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>
</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>
<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>

View File

@@ -4,7 +4,7 @@
<div class="darkbg login-container">
<h1>Sign up</h1>
{% 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 %}
<form method="post">
{% if key %}

View File

@@ -1,9 +1,9 @@
{% from 'common/macros.html' import timestamp %}
{% extends 'base.html' %}
{% block title %}{{ target_user.username }}'s profile{% endblock %}
{% block title %}{{ target_user.get_readable_name() }}'s profile{% endblock %}
{% block content %}
<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 %}
<div class="user-actions">
<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="usercard-inner">
<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 %}
<em class="user-status">{{ target_user.status }}</em>
{% 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>
{% endif %}
{% 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 %}
</ul>
{% endwith %}