diff --git a/50_bubble.py b/50_bubble.py index 2cb3620..055e0a6 100644 --- a/50_bubble.py +++ b/50_bubble.py @@ -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) diff --git a/admin.py b/admin.py index ceafcab..82dee8e 100644 --- a/admin.py +++ b/admin.py @@ -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.)" diff --git a/composer.py b/composer.py index 6d3394c..9b2f695 100644 --- a/composer.py +++ b/composer.py @@ -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' diff --git a/db-migrate.sql b/db-migrate.sql index 4fab8e2..5728e8a 100644 --- a/db-migrate.sql +++ b/db-migrate.sql @@ -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 ''; \ No newline at end of file diff --git a/feeds.py b/feeds.py index ee4f08b..3e4a16b 100644 --- a/feeds.py +++ b/feeds.py @@ -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: diff --git a/model.py b/model.py index 6c3a8cc..ee1ffd5 100644 --- a/model.py +++ b/model.py @@ -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)