Compare commits

..

11 Commits

35 changed files with 581 additions and 135 deletions

View File

@@ -1,4 +1,4 @@
from flask import Flask, session, request
from flask import Flask, session, request, render_template
from dotenv import load_dotenv
from .models import Avatars, Users, PostHistory, Posts, MOTD
from .auth import digest
@@ -7,7 +7,7 @@ from .routes.threads import get_post_url
from .constants import (
PermissionLevel, permission_level_string,
InfoboxKind, InfoboxHTMLClass,
REACTION_EMOJI,
REACTION_EMOJI, MOTD_BANNED_TAGS,
)
from .lib.babycode import babycode_to_html, EMOJI, BABYCODE_VERSION
from datetime import datetime
@@ -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.')
@@ -175,6 +176,7 @@ def create_app():
"__commit": commit,
"__emoji": EMOJI,
"REACTION_EMOJI": REACTION_EMOJI,
"MOTD_BANNED_TAGS": MOTD_BANNED_TAGS,
}
@app.context_processor
@@ -206,7 +208,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):
@@ -225,7 +227,7 @@ def create_app():
elif request.path.startswith('/api/'):
return {'error': 'not found'}, e.code
else:
return e
return render_template('common/404.html'), e.code
# this only happens at build time but
# build time is when updates are done anyway

View File

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

View File

@@ -189,6 +189,13 @@ class Model:
raise AttributeError(f"No column '{key}'")
@classmethod
def from_data(cls, data):
instance = cls(cls.table)
instance._data = dict(data)
return instance
@classmethod
def find(cls, condition):
row = db.QueryBuilder(cls.table)\
@@ -196,9 +203,7 @@ class Model:
.first()
if not row:
return None
instance = cls(cls.table)
instance._data = dict(row)
return instance
return cls.from_data(row)
@classmethod
@@ -207,11 +212,7 @@ class Model:
.where(condition, operator)\
.all()
res = []
for row in rows:
instance = cls(cls.table)
instance._data = dict(row)
res.append(instance)
return res
return [cls.from_data(row) for row in rows]
@classmethod
@@ -223,9 +224,7 @@ class Model:
row = db.insert(cls.table, columns, *values.values())
if row:
instance = cls(cls.table)
instance._data = row
return instance
return cls.from_data(row)
return None
@@ -243,7 +242,8 @@ class Model:
def select(cls, sel = "*"):
qb = db.QueryBuilder(cls.table).select(sel)
result = qb.all()
return result if result else []
# return result if result else []
return [cls.from_data(data) for data in (result if result else [])]
def update(self, data):

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})
@@ -53,7 +71,8 @@ def update_post(post_id, new_content, markup_language='babycode'):
def delete(post_id):
post = Posts.find({'id': post_id})
if not post:
return redirect(url_for('topics.all_topics'))
abort(404)
return
thread = Threads.find({'id': post.thread_id})
user = get_active_user()
@@ -85,13 +104,15 @@ def delete(post_id):
def edit(post_id):
post = Posts.find({'id': post_id})
if not post:
return redirect(url_for('topics.all_topics'))
abort(404)
return
user = get_active_user()
q = f"{Posts.FULL_POSTS_QUERY} WHERE posts.id = ?"
editing_post = db.fetch_one(q, post_id)
if not editing_post:
return redirect(url_for('topics.all_topics'))
abort(404)
return
if editing_post['user_id'] != user.id:
return redirect(url_for('topics.all_topics'))
@@ -118,7 +139,8 @@ def edit_form(post_id):
user = get_active_user()
post = Posts.find({'id': post_id})
if not post:
return redirect(url_for('topics.all_topics'))
abort(404)
return
if post.user_id != user.id:
return redirect(url_for('topics.all_topics'))

View File

@@ -1,5 +1,6 @@
from flask import (
Blueprint, render_template, request, redirect, url_for, flash
Blueprint, render_template, request, redirect, url_for, flash,
abort,
)
from .users import login_required, mod_only, get_active_user, is_logged_in
from ..db import db
@@ -32,7 +33,8 @@ def thread(slug):
POSTS_PER_PAGE = 10
thread = Threads.find({"slug": slug})
if not thread:
return redirect(url_for('topics.all_topics'))
abort(404)
return
post_count = Posts.count({"thread_id": thread.id})
page_count = max(math.ceil(post_count / POSTS_PER_PAGE), 1)
@@ -48,7 +50,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()
@@ -87,7 +88,8 @@ def thread(slug):
def reply(slug):
thread = Threads.find({"slug": slug})
if not thread:
return redirect(url_for('topics.all_topics'))
abort(404)
return
user = get_active_user()
if user.is_guest():
return redirect(url_for('.thread', slug=slug))
@@ -149,7 +151,8 @@ def lock(slug):
user = get_active_user()
thread = Threads.find({'slug': slug})
if not thread:
return redirect(url_for('topics.all_topics'))
abort(404)
return
if not ((thread.user_id == user.id) or user.is_mod()):
return redirect(url_for('.thread', slug=slug))
target_op = request.form.get('target_op')
@@ -166,7 +169,8 @@ def sticky(slug):
user = get_active_user()
thread = Threads.find({'slug': slug})
if not thread:
return redirect(url_for('topics.all_topics'))
abort(404)
return
if not ((thread.user_id == user.id) or user.is_mod()):
return redirect(url_for('.thread', slug=slug))
target_op = request.form.get('target_op')

View File

@@ -1,5 +1,6 @@
from flask import (
Blueprint, render_template, request, redirect, url_for, flash, session
Blueprint, render_template, request, redirect, url_for, flash, session,
abort,
)
from .users import login_required, mod_only, get_active_user, is_logged_in
from ..models import Users, Topics, Threads, Subscriptions
@@ -50,7 +51,8 @@ def topic(slug):
"slug": slug
})
if not target_topic:
return redirect(url_for('.all_topics'))
abort(404)
return
threads_count = Threads.count({
"topic_id": target_topic.id
@@ -88,7 +90,8 @@ def topic(slug):
def edit(slug):
topic = Topics.find({"slug": slug})
if not topic:
return redirect(url_for('.all_topics'))
abort(404)
return
return render_template("topics/edit.html", topic=topic)
@@ -98,7 +101,8 @@ def edit(slug):
def edit_post(slug):
topic = Topics.find({"slug": slug})
if not topic:
return redirect(url_for('.all_topics'))
abort(404)
return
topic.update({
"name": request.form.get('name', default = topic.name).strip(),
@@ -115,7 +119,8 @@ def edit_post(slug):
def delete(slug):
topic = Topics.find({"slug": slug})
if not topic:
return redirect(url_for('.all_topics'))
abort(404)
return
topic.delete()

View File

@@ -1,10 +1,15 @@
from flask import (
Blueprint, render_template, request, redirect, url_for, flash, session, current_app
Blueprint, render_template, request, redirect, url_for, flash, session, current_app, abort
)
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
@@ -68,6 +73,7 @@ def create_session(user_id):
"expires_at": int(time.time()) + 31 * 24 * 60 * 60,
})
def extend_session(user_id):
session_obj = Sessions.find({'key': session['pyrom_session_key']})
if not session_obj:
@@ -90,6 +96,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)
@@ -112,6 +127,19 @@ def redirect_if_logged_in(*args, **kwargs):
return decorator
def redirect_to_own(view_func):
@wraps(view_func)
def wrapper(username, *args, **kwargs):
user = get_active_user()
if username.lower() != user.username:
view_args = dict(request.view_args)
view_args.pop('username', None)
new_args = {**view_args, 'username': user.username}
return redirect(url_for(request.endpoint, **new_args))
return view_func(username, *args, **kwargs)
return wrapper
def login_required(view_func):
@wraps(view_func)
def wrapper(*args, **kwargs):
@@ -174,6 +202,53 @@ def get_prefers_theme():
return session['theme']
def anonymize_user(user_id):
deleted_user = Users.find({'username': 'deleteduser'})
from ..models import Threads, Posts
from ..lib.babycode import sanitize
threads = Threads.findall({'user_id': user_id})
posts = Posts.findall({'user_id': user_id})
revs_q = """SELECT DISTINCT m.revision_id FROM mentions m
WHERE m.mentioned_user_id = ?"""
mentioned_revs = db.query(revs_q, int(user_id))
with db.transaction():
for thread in threads:
thread.update({'user_id': int(deleted_user.id)})
for post in posts:
post.update({'user_id': int(deleted_user.id)})
revs = {}
for rev in mentioned_revs:
ph = PostHistory.find({'id': int(rev['revision_id'])})
ms = Mentions.findall({
'mentioned_user_id': int(user_id),
'revision_id': int(rev['revision_id'])
})
data = {
'text': sanitize(ph.original_markup),
'mentions': ms,
}
data['mentions'] = sorted(data['mentions'], key=lambda x: int(x.end_index), reverse=True)
revs[rev['revision_id']] = data
for rev_id, data in revs.items():
text = data['text']
for mention in data['mentions']:
text = text[:mention.start_index] + '@deleteduser' + text[mention.end_index:]
mention.delete()
res = babycode_to_html(text)
ph = PostHistory.find({'id': int(rev_id)})
ph.update({
'original_markup': text.unescape(),
'content': res.result,
})
@bp.get("/log_in")
@redirect_if_logged_in(".page", username = lambda: get_active_user().username)
def log_in():
@@ -184,7 +259,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 +314,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 +329,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,22 +360,22 @@ def sign_up_post():
@bp.get("/<username>")
def page(username):
target_user = Users.find({"username": username})
target_user = Users.find({"username": username.lower()})
if not target_user:
abort(404)
return render_template("users/user.html", target_user = target_user)
@bp.get("/<username>/settings")
@login_required
@redirect_to_own
def settings(username):
target_user = Users.find({'username': username})
if target_user.id != get_active_user().id:
return redirect('.settings', username = get_active_user().username)
return render_template('users/settings.html')
@bp.post('/<username>/settings')
@login_required
@redirect_to_own
def settings_form(username):
# we silently ignore the passed username
# and grab the correct user from the session
@@ -311,10 +392,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,13 +409,29 @@ 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))
@bp.post('/<username>/set_avatar')
@login_required
@redirect_to_own
def set_avatar(username):
user = get_active_user()
if user.is_guest():
@@ -372,6 +475,7 @@ def set_avatar(username):
@bp.post('/<username>/change_password')
@login_required
@redirect_to_own
def change_password(username):
user = get_active_user()
password = request.form.get('new_password')
@@ -394,6 +498,7 @@ def change_password(username):
@bp.post('/<username>/clear_avatar')
@login_required
@redirect_to_own
def clear_avatar(username):
user = get_active_user()
if user.is_default_avatar():
@@ -486,11 +591,9 @@ def guest_user(user_id):
@bp.get("/<username>/inbox")
@login_required
@redirect_to_own
def inbox(username):
user = get_active_user()
if username != user.username:
return redirect(url_for(".inbox", username = user.username))
new_posts = []
subscription = Subscriptions.find({"user_id": user.id})
all_subscriptions = None
@@ -628,16 +731,14 @@ def reset_link_login_form(key):
@bp.get('/<username>/invite-links/')
@login_required
@redirect_to_own
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))
if target_user.username != get_active_user().username:
return redirect(url_for('.invite_links', username=target_user.username))
invites = InviteKeys.findall({
'created_by': target_user.id
})
@@ -647,15 +748,13 @@ def invite_links(username):
@bp.post('/<username>/invite-links/create')
@login_required
@redirect_to_own
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))
if target_user.username != get_active_user().username:
return redirect(url_for('.invite_links', username=target_user.username))
return redirect(url_for('.page', username=username.lower()))
invite = InviteKeys.create({
'created_by': target_user.id,
@@ -667,15 +766,13 @@ def create_invite_link(username):
@bp.post('/<username>/invite-links/revoke')
@login_required
@redirect_to_own
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))
if target_user.username != get_active_user().username:
return redirect(url_for('.invite_links', username=target_user.username))
return redirect(url_for('.page', username=username.lower()))
invite = InviteKeys.find({
'key': request.form.get('key'),
@@ -694,10 +791,9 @@ def revoke_invite_link(username):
@bp.get('/<username>/bookmarks')
@login_required
@redirect_to_own
def bookmarks(username):
target_user = Users.find({'username': username})
if not target_user or target_user.username != get_active_user().username:
return redirect(url_for('.bookmarks', username=get_active_user().username))
target_user = get_active_user()
collections = target_user.get_bookmark_collections()
@@ -706,10 +802,40 @@ def bookmarks(username):
@bp.get('/<username>/bookmarks/collections')
@login_required
@redirect_to_own
def bookmark_collections(username):
target_user = Users.find({'username': username})
if not target_user or target_user.username != get_active_user().username:
return redirect(url_for('.bookmark_collections', username=get_active_user().username))
target_user = get_active_user()
collections = target_user.get_bookmark_collections()
return render_template('users/bookmark_collections.html', collections=collections)
@bp.get('/<username>/delete-account')
@login_required
@redirect_to_own
def delete_page(username):
target_user = get_active_user()
return render_template('users/delete_page.html')
@bp.post('/<username>/delete-account')
@login_required
@redirect_to_own
def delete_page_confirm(username):
target_user = get_active_user()
password = request.form.get('password', default='')
if not verify(target_user.password_hash, password):
flash('Incorrect password.', InfoboxKind.ERROR)
return redirect(url_for('.delete_page', username=username))
anonymize_user(target_user.id)
sessions = Sessions.findall({'user_id': int(target_user.id)})
for session_obj in sessions:
session_obj.delete()
session.clear()
target_user.delete()
return redirect(url_for('topics.all_topics'))

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

@@ -28,5 +28,4 @@
</footer>
</bitty-6-0>
<script src="{{ "/static/js/ui.js" | cachebust }}"></script>
<script src="{{ "/static/js/date-fmt.js" | cachebust }}"></script>
</body>

View File

@@ -0,0 +1,8 @@
{% extends 'base.html' %}
{% block title %}not found{% endblock %}
{% block content %}
<div class="darkbg settings-container">
<h1 class="thread-title">404 Not Found</h1>
<p>The requested URL does not exist.</p>
</div>
{% endblock %}

View File

@@ -75,7 +75,7 @@
{% endmacro %}
{% macro timestamp(unix_ts) -%}
<span class="timestamp" data-utc="{{ unix_ts }}">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span>
<span class="timestamp" data-utc="{{ unix_ts }}" data-init="convertTimestamps">{{ unix_ts | ts_datetime('%Y-%m-%d %H:%M')}} <abbr title="Server Time">ST</abbr></span>
{%- endmacro %}
{% macro babycode_editor_component(ta_name, ta_placeholder="Post body", optional=False, prefill="", banned_tags=[]) %}
@@ -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>
@@ -121,7 +121,7 @@
{% macro babycode_editor_form(ta_name, prefill = "", cancel_url="", endpoint="") %}
{% set save_button_text = "Post reply" if not cancel_url else "Save" %}
<form class="post-edit-form" method="post" action={{ endpoint }}>
<form class="post-edit-form" method="post" {%- if endpoint %}action={{ endpoint }}{% endif %}>
{{babycode_editor_component(ta_name, prefill = prefill)}}
{% if not cancel_url %}
<span>
@@ -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=MOTD_BANNED_TAGS, 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

@@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block title %}delete confirmation{% endblock %}
{% block content %}
<div class="darkbg login-container">
<h1>Confirm account deletion</h1>
<p>Are you sure you want to delete your account on {{ config.SITE_NAME }}? <strong>This action is irreversible.</strong> Your posts and threads will remain accessible to preserve history but will be de-personalized, showing up as authored by a system user. Posts that @mention you will also mention the system user instead.</p>
<p>If you wish for any and all content relating to you to be removed, you will have to ask {{ config.SITE_NAME }}'s administrators separately.</p>
<p>If you are sure, please confirm your current password below.</p>
<form method="post">
<label for="password">Confirm password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
<input class="critical" type="submit" value="Delete account">
</form>
</div>
{% endblock %}

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>
@@ -41,5 +43,8 @@
<input type="password" id="new_password2" name="new_password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
<input class="warn" type="submit" value="Change password">
</form>
<div>
<a class="linkbutton critical" href="{{ url_for('users.delete_page', username=active_user.username) }}">Delete account</a>
</div>
</div>
{% endblock %}

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 %}

View File

@@ -814,13 +814,15 @@ p {
}
.login-container > * {
width: 40%;
width: 70%;
margin: auto;
max-width: 1000px;
}
.settings-container > * {
width: 40%;
width: 70%;
margin: auto;
max-width: 1000px;
}
.avatar-form {
@@ -1409,3 +1411,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;
}

View File

@@ -814,13 +814,15 @@ p {
}
.login-container > * {
width: 40%;
width: 70%;
margin: auto;
max-width: 1000px;
}
.settings-container > * {
width: 40%;
width: 70%;
margin: auto;
max-width: 1000px;
}
.avatar-form {
@@ -1410,6 +1412,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);

View File

@@ -814,13 +814,15 @@ p {
}
.login-container > * {
width: 60%;
width: 70%;
margin: auto;
max-width: 1000px;
}
.settings-container > * {
width: 60%;
width: 70%;
margin: auto;
max-width: 1000px;
}
.avatar-form {
@@ -1410,6 +1412,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;

View File

@@ -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))}
@@ -141,7 +142,7 @@ export default class {
}
}
#previousMarkup = '';
#previousMarkup = null;
async babycodePreview(ev, el) {
if (ev.sender.classList.contains('active')) {
return;
@@ -151,11 +152,17 @@ export default class {
const previewContainer = el.querySelector('#babycode-preview-container');
const ta = document.getElementById('babycode-content');
const markup = ta.value.trim();
if (markup === '' || markup === this.#previousMarkup) {
if (markup === '') {
previewErrorsContainer.textContent = 'Type something!';
previewContainer.textContent = '';
this.#previousMarkup = '';
return;
}
if (markup === this.#previousMarkup) {
return;
}
const bannedTags = JSON.parse(document.getElementById('babycode-banned-tags').value);
this.#previousMarkup = markup;
@@ -246,4 +253,27 @@ export default class {
el.scrollIntoView();
el.focus();
}
convertTimestamps(ev, el) {
const timestamp = el.getInt('utc');
if (!isNaN(timestamp)) {
const date = new Date(timestamp * 1000);
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

@@ -1,10 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
const timestampSpans = document.getElementsByClassName("timestamp");
for (let timestampSpan of timestampSpans) {
const timestamp = parseInt(timestampSpan.dataset.utc);
if (!isNaN(timestamp)) {
const date = new Date(timestamp * 1000);
timestampSpan.textContent = date.toLocaleString();
}
}
})

View File

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

View File

@@ -51,7 +51,8 @@ $BIGGER_PADDING: 30px !default;
$PAGE_SIDE_MARGIN: 100px !default;
$SETTINGS_WIDTH: 40% !default;
$SETTINGS_WIDTH: 70% !default;
$SETTINGS_MAX_WIDTH: 1000px !default;
// **************
// BORDERS
@@ -654,15 +655,19 @@ $pagebutton_min_width: $BIG_PADDING !default;
}
$login_container_width: $SETTINGS_WIDTH !default;
$login_container_max_width: $SETTINGS_MAX_WIDTH !default;
.login-container > * {
width: $login_container_width;
margin: auto;
max-width: $login_container_max_width;
}
$settings_container_width: $SETTINGS_WIDTH !default;
$settings_container_max_width: $SETTINGS_MAX_WIDTH !default;
.settings-container > * {
width: $settings_container_width;
margin: auto;
max-width: $settings_container_max_width
}
$avatar_form_padding: $BIG_PADDING $ZERO_PADDING !default;
@@ -1369,3 +1374,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;
}
}

View File

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

View File

@@ -17,7 +17,6 @@ $br: 16px;
$thread_locked_border: 1px solid black,
$motd_border: 1px solid black,
$SETTINGS_WIDTH: 60%,
$PAGE_SIDE_MARGIN: 50px,
$link_color: black,