add mentions
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -48,7 +48,7 @@ REACTION_EMOJI = [
|
||||
]
|
||||
|
||||
MOTD_BANNED_TAGS = [
|
||||
'img', 'spoiler',
|
||||
'img', 'spoiler', 'mention'
|
||||
]
|
||||
|
||||
def permission_level_string(perm):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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>
|
||||
@@ -165,6 +165,15 @@
|
||||
{{ 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 }}
|
||||
</div>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -56,12 +56,12 @@
|
||||
</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>
|
||||
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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ $br: 8px;
|
||||
$tab_button_active_color: #8a5584,
|
||||
|
||||
$bookmarks_dropdown_background_color: $lightish_accent,
|
||||
|
||||
$mention_font_color: $fc,
|
||||
);
|
||||
|
||||
#topnav {
|
||||
|
||||
Reference in New Issue
Block a user