User flairs and locking comments

This commit is contained in:
Jaakko Keränen 2024-01-10 08:51:57 +02:00
parent 69823b3f28
commit 17b7d6eaa5
No known key found for this signature in database
GPG Key ID: BACCFCFB98DB2EDC
8 changed files with 118 additions and 35 deletions

View File

@ -178,10 +178,22 @@ Bubble is open source:
return False
return self.is_deletable(post)
def is_lockable(self, post: Post):
return self.is_deletable(post)
def is_title_editable(self, post: Post):
# Moderators can edit post titles.
return self.is_deletable(post)
def is_commenting_enabled(self, post: Post):
if not post:
return False
if self.user.role == User.ADMIN:
return True
if post.flags & Post.LOCKED_FLAG:
return False
return True
def is_antenna_enabled(self):
if self.user:
return self.user.role != User.LIMITED
@ -259,6 +271,7 @@ Bubble is open source:
else:
age = post.age(tz=self.tz)
bell = ' 🔔' if post.num_notifs else ''
flair = f' [{post.poster_flair}]' if post.poster_flair else ''
SHORT_PREVIEW_LEN = 160
@ -301,6 +314,8 @@ Bubble is open source:
else:
post_icon = post.poster_avatar
post_label = post.poster_name
if not (is_user_post and context and context.id == post.subspace):
post_label += flair
post_path = f'/u/{post.poster_name}'
meta_icon = '💬'
@ -316,7 +331,7 @@ Bubble is open source:
# Activity feeds use ts_comment timestamps, so the post author's name is at the top
# because the meta line is associated with the latest comment instead.
if is_activity_feed and sub:
post_label += f' · {post.poster_avatar} {post.poster_name}'
post_label += f' · {post.poster_avatar} {post.poster_name}{flair}'
src = f'=> {post_path} {post_icon} {post_label}{rotation}\n'
@ -861,7 +876,7 @@ Deleting a subspace will delete all posts and comments in the subspace, i.e., th
elif req.path.startswith(self.path + 'comment/'):
return make_comment(session)
elif re.match(r'^(like|unlike|vote|follow|unfollow|mute|unmute|notif|thanks|react|unreact|report|remind|transmit)/.*',
elif re.match(r'^(like|unlike|vote|follow|unfollow|mute|unmute|lock|unlock|notif|thanks|react|unreact|report|remind|transmit)/.*',
req.path[len(self.path):]):
return user_actions(session)

View File

@ -60,6 +60,7 @@ def admin_actions(session):
page += "## Users\n\n"
page += f'=> review-users/ ✔️ Review limited users\n'
page += f'=> flair/{token} 📛 Set user flair\n'
page += f'=> create-user/{token} 👤 Create new user\n'
page += f'=> password/{token} 🔑 Generate a random password for user\n'
page += f'=> revoke/{token} 🛂 Revoke certificates of user\n'
@ -79,7 +80,7 @@ def admin_actions(session):
return page
found = re.search(r'^admin/(create-user|password|revoke|delete-user|omit-subspace|include-subspace|delete-subspace)/([0-9a-zA-Z]{10})$',
found = re.search(r'^admin/(create-user|password|revoke|flair|delete-user|omit-subspace|include-subspace|delete-subspace)/([0-9a-zA-Z]{10})$',
req.path[len(session.path):])
if not found:
return 59, "Bad request"
@ -152,6 +153,21 @@ def admin_actions(session):
db.remove_certificate(user, None)
page += f'Certificates of user "{name}" (ID: {user.id}) have been unregistered.\n'
elif action == 'flair':
try:
parts = clean_query(req).split()
name = parts[0]
if not is_valid_name(name):
return 10, 'Enter user name followed by flair (e.g., "john Friendly"):'
except:
return 10, 'Enter user name followed by flair (e.g., "john Friendly"):'
flair = clean_query(req)[len(name):].strip()
user = db.get_user(name=name)
if not user:
return 51, 'Not found'
db.update_user(user, flair=flair)
page += f'Flair of user "{name}" (ID: {user.id}) has been set to: [{flair}]\n'
elif action == 'delete-user':
if not is_valid_name(name):
return 10, "Enter user to delete: (NOTE: All of their posts and comments will be deleted.)"

View File

@ -283,7 +283,7 @@ def make_composer_page(session):
return 30, link
if post_action == 'featured-link':
db.update_post(post, flags=post.flags ^ Post.DISABLE_FEATURED_LINK)
db.update_post(post, flags=post.flags ^ Post.DISABLE_FEATURED_LINK_FLAG)
db.update_post_summary(post)
return 30, link
@ -389,7 +389,7 @@ def make_composer_page(session):
if not subspace.flags & Subspace.OMIT_FROM_ALL_FLAG:
page += f'=> {link}/omit-all {CHECKS[nonzero(post.flags & Post.OMIT_FROM_ALL_FLAG)]} Omit {post_type.lower()} from All Posts\n'
page += f'=> {link}/omit-feed {CHECKS[nonzero(post.flags & Post.OMIT_FROM_FEED_FLAG)]} Omit {post_type.lower()} from Gemini/Atom feed\n'
page += f'=> {link}/featured-link {CHECKS[is_zero(post.flags & Post.DISABLE_FEATURED_LINK)]} Feature first link\n'
page += f'=> {link}/featured-link {CHECKS[is_zero(post.flags & Post.DISABLE_FEATURED_LINK_FLAG)]} Feature first link\n'
else:
page += f"=> /{subspace.title()} Limited account: post is visible only in {subspace.title()}\n"
if is_issue_tracker:
@ -489,12 +489,14 @@ def make_comment(session):
post = db.get_post(post_id)
if not post:
return 51, 'Not found'
if post.flags & Post.LOCKED_FLAG and session.user.role != User.ADMIN:
return 61, 'Post is locked'
special = None
if session.is_gemini:
com_text = clean_query(req)
if len(com_text) == 0:
return 10, 'New comment: (draft by ending with a backslash; see Help for special commands)'
return 10, 'New comment: (draft a long comment by ending with a backslash)'
if com_text == '.':
special = 'draft'

View File

@ -30,3 +30,6 @@ UPDATE users SET notif=notif|0x040000;
-- Migration from v6 to v7 --
UPDATE users SET notif=notif|0x100000;
-- Migration from v7 to v8 --
ALTER TABLE users ADD COLUMN flair VARCHAR(30) DEFAULT '';

View File

@ -190,7 +190,7 @@ def make_post_page_or_configure_feed(session):
# If there are plain URIs in the content, make segments for them.
nonlinks = parse_nonlink_uris(body)
if nonlinks:
db.update_post(post, flags=post.flags | Post.DISABLE_FEATURED_LINK)
db.update_post(post, flags=post.flags | Post.DISABLE_FEATURED_LINK_FLAG)
for uri in nonlinks:
db.create_segment(post, Segment.LINK, url=uri, content='')
db.update_post_summary(post)
@ -248,6 +248,11 @@ def make_post_page_or_configure_feed(session):
if not session.is_context_tracker and (session.user.id == post.user or session.is_user_mod):
actions.append(f'=> /edit-tags/{post.id} 🏷️ Add/remove tags\n')
actions.append(f'=> /remind/{post.id} 🔔 Remind me\n')
if session.is_lockable(post):
if post.flags & Post.LOCKED_FLAG:
actions.append(f'=> /unlock/{post.id} 🔓 Unlock comments\n')
else:
actions.append(f'=> /lock/{post.id} 🔒 Lock comments\n')
if session.is_movable(post):
actions.append(f'=> /edit/{post.id}/move/{session.get_token()} Move to subspace\n')
if post.user != session.user.id and not session.is_user_mod and session.user.role != User.ADMIN:
@ -288,7 +293,9 @@ def make_post_page_or_configure_feed(session):
if arg2 == '/group':
subspace = db.get_subspace(id=post.subspace)
if session.c_user:
page = f'# {session.c_user.avatar} u/{session.c_user.name}\n'
page = f'# {session.c_user.avatar} {session.c_user.name}\n'
if session.c_user.flair:
page += f"[{session.c_user.flair}]\n"
else:
page = f'# {subspace.title()}\n'
page += f'=> /{subspace.title()} {subspace.title()}\n'
@ -351,7 +358,8 @@ def make_post_page(session, post):
return 51, 'Not found'
page += f'=> /help/deleted-post 🔒 Comment on a deleted post (ID:{post_id})\n\n'
page += session.render_post(focused_cmt)
page += f'\n=> /u/{focused_cmt.poster_name} {focused_cmt.poster_avatar} {focused_cmt.poster_name}\n'
flair = f' [{focused_cmt.poster_flair}]' if focused_cmt.poster_flair else ''
page += f'\n=> /u/{focused_cmt.poster_name} {focused_cmt.poster_avatar} {focused_cmt.poster_name}{flair}\n'
page += f'{focused_cmt.age()}\n'
# Comment actions.
@ -361,7 +369,7 @@ def make_post_page(session, post):
if session.is_editable(focused_cmt):
actions.append(f'=> /edit/{focused_cmt.id} ✏️ Edit\n')
if post and session.user and not session.is_context_locked and \
session.user.role != User.LIMITED:
session.user.role != User.LIMITED and session.is_commenting_enabled(post):
actions.append(f'=> /comment/{post.id} 💬 Comment\n')
if session.is_thanks_enabled() and focused_cmt.user != user.id:
actions.append(f'=> /thanks/{focused_cmt.id} 🙏 Give thanks\n')
@ -429,7 +437,8 @@ def make_post_page(session, post):
page += '\n'
if post.tags:
page += '### ' + post.tags + '\n'
poster_link = f'=> /u/{post.poster_name} {post.poster_avatar} {post.poster_name}\n'
flair = f" [{post.poster_flair}]" if post.poster_flair else ""
poster_link = f'=> /u/{post.poster_name} {post.poster_avatar} {post.poster_name}{flair}\n'
if session.is_context_tracker:
page += f'=> /{session.context.title()} 🐞 Issue #{post.issueid} in {session.context.title()}\n'
elif not session.c_user:
@ -451,6 +460,8 @@ def make_post_page(session, post):
listed.append(f'{r} {reactions[r]}')
if listed:
page += ' · ' + ' '.join(listed)
if post.flags & Post.LOCKED_FLAG:
page += '\n🔒 Locked'
page += '\n'
# Post actions.
@ -461,7 +472,7 @@ def make_post_page(session, post):
page += f'=> /edit/{post.id} ✏️ Edit {kind}\n'
elif session.is_title_editable(post):
page += f'=> /edit/{post.id}/mod-title ✏️ Edit {kind} title\n'
if session.user.role != User.LIMITED:
if session.user.role != User.LIMITED and session.is_commenting_enabled(post):
page += f'=> /comment/{post.id} 💬 Comment\n'
# Reactions.
@ -567,10 +578,12 @@ def make_post_page(session, post):
cmt.ymd_hm(tz=session.tz, date_fmt='%b %d', time_prefix='at ') if elapsed_hours < 24 * 180 else \
cmt.ymd_hm(tz=session.tz, time_prefix='at ')
cmt_flair = f" [{cmt.poster_flair}]" if cmt.poster_flair else ""
if not session.is_archive:
src = f'=> /u/{cmt.poster_name}/{cmt.id} {cmt.poster_avatar} {cmt.poster_name} · {comment_age}:\n'
src = f'=> /u/{cmt.poster_name}/{cmt.id} {cmt.poster_avatar} {cmt.poster_name}{cmt_flair} · {comment_age}:\n'
else:
src = f'=> /u/{cmt.poster_name} {cmt.poster_avatar} {cmt.poster_name} · {comment_age}:\n'
src = f'=> /u/{cmt.poster_name} {cmt.poster_avatar} {cmt.poster_name}{cmt_flair} · {comment_age}:\n'
comment_body = session.render_post(cmt)
src += comment_body
@ -615,6 +628,7 @@ def make_post_page(session, post):
and session.user
and session.user.role != User.LIMITED
and post
and session.is_commenting_enabled(post)
and not session.is_context_locked
and not display_order_desc
and (is_comment_page or len(comments) >= 1)):
@ -693,6 +707,8 @@ def make_feed_page(session):
if c_user and (c_user.info or c_user.url):
if c_user.info:
topinfo += c_user.info + '\n'
if c_user.flair:
topinfo += f"[{c_user.flair}]\n"
if c_user.url:
topinfo += f'=> {c_user.url}\n'
elif context:

View File

@ -250,12 +250,14 @@ class User:
FEED_MASK = HOME_FOLLOWED_FEED_FLAG | HOME_USERS_FEED_FLAG | HOME_NO_USERS_FEED_FLAG
def __init__(self, id, name, info, url, recovery, avatar, role, flags, notif, email, email_inter, \
email_range, password, ts_password, ts_created, ts_active, sort_post, sort_cmt,
def __init__(self, id, name, info, flair, url, recovery, avatar, role, flags, notif, \
email, email_inter, email_range, \
password, ts_password, ts_created, ts_active, sort_post, sort_cmt,
timezone):
self.id = id
self.name = name
self.info = info
self.flair = flair
self.url = url
self.recovery = recovery
self.avatar = avatar
@ -350,16 +352,17 @@ class Post:
TAG_POLL = 'poll'
TAG_CLOSED = 'closed'
OMIT_FROM_FEED_FLAG = 0x1
OMIT_FROM_ALL_FLAG = 0x2
DISABLE_FEATURED_LINK = 0x4
OMIT_FROM_FEED_FLAG = 0x1
OMIT_FROM_ALL_FLAG = 0x2
DISABLE_FEATURED_LINK_FLAG = 0x4
LOCKED_FLAG = 0x8
SORT_CREATED, SORT_ACTIVE, SORT_HOTNESS = range(3)
def __init__(self, id, subspace, parent, user, issueid, title, flags, is_draft, is_pinned,
num_cmts, num_likes, tags, ts_created, ts_edited, summary,
sub_name=None, sub_owner=None, poster_avatar=None, poster_name=None, num_notifs=0,
num_per_day=None, ts_comment=None):
sub_name=None, sub_owner=None, poster_avatar=None, poster_name=None,
poster_flair=None, num_notifs=0, num_per_day=None, ts_comment=None):
self.id = id
self.subspace = subspace
self.parent = parent
@ -380,6 +383,7 @@ class Post:
self.sub_owner = sub_owner
self.poster_avatar = poster_avatar
self.poster_name = poster_name
self.poster_flair = poster_flair
self.num_notifs = num_notifs
self.num_per_day = num_per_day
@ -491,6 +495,7 @@ class Database:
db.execute("""CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(30) UNIQUE,
flair VARCHAR(30) DEFAULT '',
info VARCHAR(1000) DEFAULT '',
url VARCHAR(1000) DEFAULT '',
recovery VARCHAR(1000) DEFAULT '',
@ -755,7 +760,7 @@ class Database:
WHERE {' AND '.join(cond)}""", values)
users = []
for (id, name, avatar, ts_created, ts_active) in cur:
user = User(id, name, None, None, None,
user = User(id, name, None, None, None, None,
avatar, role, 0, 0, None, None, None, None, None,
ts_created, ts_active, None, None, None)
users.append(user)
@ -786,6 +791,7 @@ class Database:
u.id,
u.name,
u.info,
u.flair,
u.url,
u.recovery,
u.avatar,
@ -807,10 +813,10 @@ class Database:
JOIN subspaces s ON s.owner=u.id
WHERE {' AND '.join(cond)}""", values)
for (id, name, info, url, recovery, avatar, role, flags, notif, email, email_inter,
for (id, name, info, flair, url, recovery, avatar, role, flags, notif, email, email_inter,
email_range, password, ts_password, ts_created, ts_active,
sort_post, sort_cmt, timezone, user_subspace_id) in cur:
user = User(id, name, info, url, recovery, avatar, role, flags, notif,
user = User(id, name, info, flair, url, recovery, avatar, role, flags, notif,
email, email_inter, email_range,
password, ts_password,
ts_created, ts_active,
@ -860,7 +866,8 @@ class Database:
def update_user(self, user: Union[User, int],
role=None,
avatar=None, name=None, info=None, url=None, recovery=None,
avatar=None, name=None, info=None, flair=None,
url=None, recovery=None,
email=None, email_inter=None, email_range=None,
notif=None, flags=None,
password=None, password_expiration_offset_minutes=0,
@ -883,6 +890,9 @@ class Database:
if info != None:
stm.append('info=?')
values.append(info)
if flair != None:
stm.append('flair=?')
values.append(flair)
if url != None:
stm.append('url=?')
values.append(url)
@ -1226,8 +1236,8 @@ class Database:
mods = []
for (id, avatar, name, ts_active) in cur:
mods.append(User(id, name, None, None, None, avatar, None, None, None, None, None, None, None,
None, None, ts_active, None, None, None))
mods.append(User(id, name, None, None, None, None, avatar, None, None, None, None,
None, None, None, None, None, ts_active, None, None, None))
return mods
def modify_mods(self, subspace, actor=None, add=None, remove=None):
@ -1319,7 +1329,7 @@ class Database:
render = ''
# Use only the first link/attachment.
if not post.flags & Post.DISABLE_FEATURED_LINK:
if not post.flags & Post.DISABLE_FEATURED_LINK_FLAG:
for seg in filter(lambda s: s.type in [Segment.LINK, Segment.ATTACHMENT], segments):
# No web URLs in the feeds.
if seg.url.lower().startswith('http'):
@ -1762,6 +1772,8 @@ class Database:
ts_range=None,
limit=None,
page=0):
if type(subspace) is Subspace:
subspace = subspace.id
cur = self.conn.cursor()
where_stm = []
values = [notifs_for_user_id]
@ -1795,7 +1807,7 @@ class Database:
if subspace != None:
where_stm.append('p.subspace=?')
values.append(subspace.id)
values.append(subspace)
PIN_ORDER = 'is_pinned DESC, '
elif ts_range != None:
where_stm.append('(UNIX_TIMESTAMP(p.ts_edited)>=? AND UNIX_TIMESTAMP(p.ts_edited)<?)')
@ -1865,6 +1877,7 @@ class Database:
sub1.owner, -- sub2.owner,
u.avatar,
u.name AS u_name,
u.flair,
(SELECT COUNT(notifs.id)
FROM notifs
WHERE notifs.dst=? AND notifs.post=p.id AND NOT notifs.is_hidden
@ -1928,7 +1941,7 @@ class Database:
posts = []
for (id, subspace, parent, user, issueid, title, flags, is_draft, is_pinned,
num_cmts, num_likes, tags, ts_created, ts_edited, ts_comment,
summary, sub_name, sub_owner, poster_avatar, poster_name, num_notifs,
summary, sub_name, sub_owner, poster_avatar, poster_name, flair, num_notifs,
_num_people, _date_created, _row_num, num_per_day) in cur:
posts.append(Post(id, subspace, parent, user, issueid,
title,
@ -1942,6 +1955,7 @@ class Database:
sub_owner=sub_owner,
poster_avatar=poster_avatar,
poster_name=poster_name,
poster_flair=flair,
num_notifs=num_notifs,
num_per_day=num_per_day))
return posts
@ -2726,16 +2740,16 @@ class Search:
for pt in p_terms:
values += [pt, pt, pt]
cur.execute(f"""
SELECT UNIX_TIMESTAMP(ts_active), id, name, avatar, info, url
SELECT UNIX_TIMESTAMP(ts_active), id, name, avatar, info, flair, url
FROM users
WHERE {' AND '.join(cond)}
ORDER BY ts_active DESC
LIMIT {ulimit}
OFFSET {ulimit * page_index}
""", values)
for (ts, id, name, avatar, info, url) in cur:
for (ts, id, name, avatar, info, flair, url) in cur:
self.results.append(((exact_match(name), ts),
User(id, name, info, url, None, avatar, None, None, None,
User(id, name, info, flair, url, None, avatar, None, None, None,
None, None, None, None, None, ts, None, None, None, None)))
# Subspaces.

19
user.py
View File

@ -28,6 +28,23 @@ def user_actions(session):
db.modify_likes(session.user, post, add=(action == 'like'))
return 30, post.page_url()
elif req.path.startswith(session.path + 'lock/') or \
req.path.startswith(session.path + 'unlock/'):
if not user:
return 60, 'Login required'
if user.role == User.LIMITED:
return 61, 'Not authorized'
found = re.match(r'^(lock|unlock)/(\d+)$', req.path[len(session.path):])
action = found.group(1)
post_id = int(found.group(2))
post = db.get_post(id=post_id)
if not post:
return 51, 'Not found'
if not session.is_lockable(post):
return 60, 'Not authorized'
db.update_post(post, flags=post.flags ^ Post.LOCKED_FLAG)
return 30, post.page_url() + '/more'
elif req.path.startswith(session.path + 'react/') or \
req.path.startswith(session.path + 'unreact/'):
if not user:
@ -217,7 +234,7 @@ def user_actions(session):
target_type = FOLLOW_POST if is_follow else MUTE_POST
target_id = int(post_id)
post = db.get_post(target_id)
dst = post.page_url()
dst = post.page_url() + '/more'
else:
target_type = FOLLOW_SUBSPACE if is_follow else MUTE_SUBSPACE
sub_name = (u_sub if u_sub != None else s_sub)[2:]

View File

@ -66,7 +66,7 @@ class Emailer (threading.Thread):
# Check that the current hour is not excluded.
if is_hour_in_range(cur_hour, email_range):
continue
pending_notifs.append(User(id, name, None, None, None, None, None,
pending_notifs.append(User(id, name, None, None, None, None, None, None,
None, enabled_types, email, None,
email_range, None, None, None, None,
None, None, None))