Bubble [main]

Flairs: Composer UI, more sophisticated rendering

=> ce031e623bc7c8cabad643d4d077aa79928d9877

diff --git a/50_bubble.py b/50_bubble.py
index a0a236c..6efc398 100644
--- a/50_bubble.py
+++ b/50_bubble.py
@@ -178,6 +178,13 @@ Bubble is open source:
                 return True
             return post.subspace in self.user.moderated_subspace_ids
 
+        def is_moderated(self, post: Post):
+            if not self.user:
+                return False
+            if self.user.role == User.ADMIN:
+                return True
+            return post.subspace in self.user.moderated_subspace_ids
+
         def is_movable(self, post: Post):
             if post.issueid:
                 return False
diff --git a/admin.py b/admin.py
index 82dee8e..da01c48 100644
--- a/admin.py
+++ b/admin.py
@@ -55,15 +55,20 @@ def admin_actions(session):
 
         return page
 
+    if req.path == session.path + 'admin/flair':
+        if is_empty_query(req):
+            return 10, "Edit flairs of user:"
+        return 30, session.path + 'settings/flair/' + clean_query(req) + '/'
+
     if req.path == session.path + 'admin/':
         token = session.get_token()
 
         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'=> flair 📛 Edit user flairs\n'
         page += f'=> password/{token} 🔑 Generate a random password for user\n'
         page += f'=> revoke/{token} 🛂 Revoke certificates of user\n'
+        page += f'=> create-user/{token} 👤 Create new user\n'
         page += f'=> delete-user/{token} ❌ Delete user\n'
 
         page += "\n## Subspaces\n\n"
@@ -153,20 +158,20 @@ 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 == '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):
diff --git a/feeds.py b/feeds.py
index 5111fa7..8753d30 100644
--- a/feeds.py
+++ b/feeds.py
@@ -256,6 +256,11 @@ def make_post_page_or_configure_feed(session):
                     actions.append(f'=> /unlock/{post.id} 🔓 Unlock comments\n')
                 else:
                     actions.append(f'=> /lock/{post.id} 🔒 Lock comments\n')
+            if session.is_moderated(post):
+                if session.user.role == User.ADMIN:
+                    actions.append(f'=> /settings/flair/{post.poster_name}/add/ 📛 Set flair on {post.poster_name}\n')
+                else:
+                    actions.append(f'=> /settings/flair/{post.poster_name}/add/-/{post.sub_name} 📛 Set subspace flair on {post.poster_name}\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:
@@ -392,6 +397,12 @@ def make_post_page(session, post):
                     actions.append(f'=> /thanks/{focused_cmt.id} 🙏 Give thanks\n')
                 actions.append(f'=> /remind/{focused_cmt.id} 🔔 Remind me\n')
 
+                if session.is_moderated(focused_cmt):
+                    if session.user.role == User.ADMIN:
+                        actions.append(f'=> /settings/flair/{focused_cmt.poster_name}/add/ 📛 Set flair on {focused_cmt.poster_name}\n')
+                    else:
+                        actions.append(f'=> /settings/flair/{focused_cmt.poster_name}/add/-/{focused_cmt.sub_name} 📛 Set subspace flair on {focused_cmt.poster_name}\n')
+
                 actions.append(f'=> /report/{focused_cmt.id} ⚠️ Report\n')
                 if not session.is_editable(focused_cmt) and session.is_deletable(focused_cmt):
                     actions.append(f'=> /edit/{focused_cmt.id}/delete/{session.get_token()} ❌ Delete comment\n')
@@ -973,9 +984,14 @@ def make_feed_page(session):
                         page += '=> /tag 🏷️ Tags\n'
 
                 # Settings.
-                if context and not c_user and session.is_user_mod:
-                    page += f'=> /{context.title()}/admin 🌒 Subspace admin\n'
-                page += "=> /settings ⚙️ Settings\n\n"
+                page += "\n=> /settings ⚙️ Settings\n"
+                if user.role == User.ADMIN and c_user and user.id != c_user.id:
+                    page += f'=> /settings/flair/{c_user.name} 📛 Flairs on {c_user.name}\n'
+                if context and (not c_user or user.id == c_user.id):
+                    page += f'=> /settings/flair/{user.name}/add/-/{context.name} 📛 Set subspace flair\n'
+                    if session.is_user_mod:
+                        page += f'=> /{context.title()}/admin 🌒 Subspace admin\n'
+                page += '\n'
 
                 if session.is_antenna_enabled() and c_user and user.id == c_user.id:
                     for i, label in enumerate(session.bubble.antenna_labels):
diff --git a/model.py b/model.py
index 1330c77..95114ed 100644
--- a/model.py
+++ b/model.py
@@ -78,6 +78,7 @@ class Notification:
     REMINDER                     = 0x080000
     REPORT                       = 0x100000
     USER_RENAMED                 = 0x200000
+    USER_FLAIR_CHANGED           = 0x400000
 
     ALL_MASK                     = 0xffffff
 
@@ -85,6 +86,7 @@ class Notification:
     PRIORITY = {
         REACTION: -1, # only one kept
         USER_RENAMED: -1,
+        USER_FLAIR_CHANGED: -1,
 
         COMMENT_IN_FOLLOWED_SUBSPACE: 0,
         POST_IN_FOLLOWED_SUBSPACE: 1,
@@ -190,6 +192,9 @@ class Notification:
         elif self.type == Notification.USER_RENAMED:
             event = f'renamed their account (ID:{self.src})'
             icon = '👤 '
+        elif self.type == Notification.USER_FLAIR_CHANGED:
+            event = 'changed their flair'
+            icon = '📛 '
         elif self.type == Notification.SUBSPACE_CREATED:
             event = f'created the subspace s/{self.subname}'
             icon = '🌘 '
@@ -229,19 +234,39 @@ class Notification:
         return f'/notif/{self.id}', f'{icon}{self.src_name if with_src else ""} {event}{hm_time}{age}'
 
 
+class Flair:
+    TYPES = [
+        ('🛂', 'Note from moderator'),
+        ('👤', 'Self description'),
+        ('🏝️', 'Absence'),
+        ('🗣️', 'Interaction style'),
+        ('✍️', 'Writing style'),
+        ('', 'Custom note'),
+    ]
+    ENC_SYMBOLS = ['M', 'd', 'a', 'i', 'w', 'n']
+
+    MODERATOR_NOTE   = 0
+    SELF_DESCRIPTION = 1
+    CUSTOM_NOTE      = 5
+
+    def __init__(self, scope, flair_type, label, is_admin_assigned):
+        assert(flair_type < len(Flair.TYPES))
+        self.scope = scope
+        self.type = flair_type
+        self.label = label
+        self.is_admin_assigned = is_admin_assigned
+
+    def icon(self):
+        return Flair.TYPES[self.type][0]
+
+    def description(self):
+        return Flair.TYPES[self.type][1]
+
+
 class User:
     # Roles:
     BASIC, ADMIN, LIMITED = range(3)
 
-    # Flair types:
-    FLAIRS = {
-        '♡': 'Self description',
-        '🏝️': 'Absence',
-        '✍️': 'Writing style',
-        '🗣️': 'Interaction style',
-        '🛂': 'Note from moderator',
-    }
-
     # Sort modes:
     SORT_POST_RECENT    = 'r'
     SORT_POST_HOTNESS   = 'h'
@@ -298,6 +323,32 @@ class User:
 
         return None
 
+    def parse_flair(user_flair):
+        flairs = []
+
+        for flair in user_flair.split('\n'):
+            try:
+                flags, scope, enc_type, label = flair.split('\t')
+                is_admin_assigned = (flags == '*')
+                scope = int(scope)
+                type = Flair.ENC_SYMBOLS.index(enc_type)
+                flairs.append(Flair(scope, type, label, is_admin_assigned))
+            except Exception as x:
+                print(x)
+
+        flairs = list(sorted(flairs, key=lambda f: (f.scope, f.type, f.label)))
+
+        return flairs
+
+    def unparse_flair(flairs):
+        result = []
+        for f in flairs:
+            assert(type(f.scope) == int)
+            #icon = f'{f.icon} ' if f.icon else ''
+            label = f.label.replace('\t', '') # remove separators
+            result.append(f"{'*' if f.is_admin_assigned else ''}\t{f.scope}\t{Flair.ENC_SYMBOLS[f.type]}\t{label}")
+        return '\n'.join(result)
+
     def render_flair(user_flair, context, abbreviate=False, long_form=False, db=None,
                      user_mod=False, user_op=False):
         """
@@ -316,56 +367,50 @@ class User:
             return ''
 
         out = ''
+        has_abbrev = False
 
-        for flair in user_flair.split('\n'):
-            pos = flair.find(':')
-            scope = flair[:pos].strip() if pos >= 0 else None
-            is_admin_assigned = (scope and scope.startswith('*'))
-            if is_admin_assigned:
-                scope = scope[1:]
-            label = flair[pos + 1:].strip() if pos >= 0 else flair
-            icon = ''
-            if len(label):
-                for key in User.FLAIRS:
-                    if label.startswith(key):
-                        icon = key
-                        label = label[len(key):].strip()
-                        break
-
+        for flair in User.parse_flair(user_flair):
             #print(user_flair, icon, label, scope)
 
-            has_abbrev = False
-
             if long_form:
                 # Show everything in the long form.
-                if icon:
-                    out += icon + ' '
-                if scope:
-                    subspace = db.get_subspace(id=abs(int(scope)))
+                if flair.icon():
+                    out += flair.icon() + ' '
+                if flair.scope:
+                    subspace = db.get_subspace(id=flair.scope)
                     if subspace:
                         scope = f" (in {subspace.title()})"
                     else:
                         scope = " (in a deleted subspace)"
                 else:
                     scope = ''
-                if icon:
-                    out += f"{User.FLAIRS[icon]}: "
-                elif not scope and is_admin_assigned:
+                if flair.icon():
+                    out += f"{flair.description()}: "
+                elif not scope and flair.is_admin_assigned:
                     out += '📛 Assigned flair: '
                 else:
                     out += '📛 Personal flair: '
-                out += f"{label}{scope}{' (set by admin)' if is_admin_assigned else ''}\n"
-
-            elif not scope or (context and int(scope) == context.id):
-                # A global flair is displayed everywhere, otherwise the scope must match
-                # current context.
-                if len(out): out += ' ' if abbreviate else ', '
-                out += icon
-                if not abbreviate or not scope:
-                    if len(out): out += ' '
-                    out += label
-                elif not icon:
-                    has_abbrev = True
+                out += f"{flair.label}{scope}{' (set by admin)' if flair.is_admin_assigned else ''}\n"
+
+            elif flair.scope == 0 or (context and flair.scope == context.id):
+                if abbreviate:
+                    if not flair.icon() or flair.type == Flair.SELF_DESCRIPTION:
+                        has_abbrev = True
+                        continue
+                    # Just showing icons and "...".
+                    out += flair.icon()
+                    # elif flair.label:
+                    #     has_abbrev = True
+                else:
+                    # Showing icons and labels.
+                    if len(out):
+                        out += ', '
+
+                    icon = flair.icon() if flair.type != Flair.SELF_DESCRIPTION else ''
+                    out += icon
+                    if flair.label:
+                        if icon: out += ' '
+                        out += flair.label
 
         if not long_form:
             if user_op or user_mod:
@@ -1034,6 +1079,37 @@ class Database:
                 cur.execute(f"UPDATE subspaces SET name=? WHERE owner=?", (name, user))
             self.commit()
 
+    def add_flair(self, user, flair, editor: User):
+        flairs = [flair]
+        for f in User.parse_flair(user.flair):
+            if f.scope == flair.scope and f.type == flair.type:
+                # The new one replaces this one.
+                if f.is_admin_assigned and editor.role != User.ADMIN:
+                    raise GeminiError(61, 'Not authorized')
+                continue
+            flairs.append(f)
+        self.update_user(user, flair=User.unparse_flair(flairs))
+        assert(flair.scope != None)
+        self.notify_moderators(Notification.USER_FLAIR_CHANGED,
+                               editor,
+                               None,
+                               subspace=self.get_subspace(id=flair.scope) if flair.scope != 0 else None)
+
+    def remove_flair(self, user, flair, editor: User):
+        flairs = []
+        for f in User.parse_flair(user.flair):
+            if f.scope == flair.scope and f.type == flair.type:
+                # This will be removed.
+                if not f.is_admin_assigned or editor.role == User.ADMIN:
+                    continue
+            flairs.append(f)
+        self.update_user(user, flair=User.unparse_flair(flairs))
+        assert(flair.scope != None)
+        self.notify_moderators(Notification.USER_FLAIR_CHANGED,
+                               editor,
+                               None,
+                               subspace=self.get_subspace(id=flair.scope) if flair.scope != 0 else None)
+
     def destroy_user(self, user: User):
         # This will already delete all posts made in the user's own subspace.
         self.destroy_subspace(self.get_subspace(owner=user.id))
@@ -2267,25 +2343,29 @@ class Database:
         """, (comment_id,))
         self.commit()
 
-    def notify_report(self, user: User, post: Post):
-        if post.user == user.id:
+    def notify_moderators(self, notif_type, user:User, post:Post=None, subspace:Subspace=None):
+        if notif_type == Notification.REPORT and post.user == user.id:
             # No self-reporting.
             return
         cur = self.conn.cursor()
         # Always notify the administrator.
-        cur.execute("""
-            INSERT IGNORE INTO notifs (type, dst, src, post)
-            VALUES (?, 1, ?, ?)
-        """, (Notification.REPORT, user.id, post.id))
-        subspace = self.get_subspace(id=post.subspace)
-        # Notify each moderator of the post's subspace.
-        cur.execute("""
-            INSERT IGNORE INTO notifs (type, dst, src, post)
-            SELECT ?, user, ?, ?
-            FROM mods
-            WHERE subspace=? AND NOT user IN (?, ?, ?)
-        """, (Notification.REPORT, user.id, post.id, post.subspace,
-              user.id, post.user, subspace.owner))
+        if user.id != 1:
+            cur.execute("""
+                INSERT IGNORE INTO notifs (type, dst, src, post)
+                VALUES (?, 1, ?, ?)
+            """, (notif_type, user.id, post.id if post else None))
+        if not subspace and post:
+            subspace = self.get_subspace(id=post.subspace)
+        if subspace:
+            # Notify each moderator of the post's subspace.
+            cur.execute("""
+                INSERT IGNORE INTO notifs (type, dst, src, post)
+                SELECT ?, user, ?, ?
+                FROM mods
+                WHERE subspace=? AND NOT user IN (?, ?, ?)
+            """, (notif_type, user.id, post.id if post else None,
+                subspace.id,
+                user.id, post.user if post else user.id, subspace.owner))
         self.commit()
 
     def notify_reminder(self, user: User, post: Post):
diff --git a/settings.py b/settings.py
index 9733a2c..c75a597 100644
--- a/settings.py
+++ b/settings.py
@@ -1,7 +1,7 @@
 import math
 import pytz
 from utils import *
-from model import Notification, User, Subspace, FOLLOW_USER, FOLLOW_SUBSPACE, \
+from model import Notification, User, Flair, Subspace, FOLLOW_USER, FOLLOW_SUBSPACE, \
     MUTE_USER, MUTE_SUBSPACE
 
 
@@ -14,6 +14,216 @@ def modify_reactions(db, user, user_flag, notif_type):
     db.update_user(user, flags=user.flags ^ user_flag, notif=notif)
 
 
+def space_separated(a, b):
+    return a + ' ' + b if a and b else a + b
+
+
+def make_flair_composer_page(session):
+    # The flair composer can be used as a user editing their own flairs,
+    # a moderator removing subspace flairs that they moderate, or an
+    # admin editing flairs of any user without restrictions.
+    req = session.req
+    db = session.db
+    user = session.user
+    user_mod = False
+    page = ''
+
+    TEXT_LIMIT = 50
+    TEXT_PROMPT = f'Enter flair text (max. {TEXT_LIMIT} charecters):'
+
+    found = re.search(r'/settings/flair/([\w-]+)', req.path)
+    flair_user = found[1] if found else user.name
+    base_url = session.path + f'settings/flair/' + flair_user
+    moderated_subs = db.get_moderated_subspace_ids(user)
+
+    if not user:
+        return 60, 'Login required'
+    if user.role == User.LIMITED:
+        return 61, 'Not authorized'
+    if user.name != flair_user and user.role != User.ADMIN:
+        if len(moderated_subs):
+            user_mod = True
+        else:
+            return 61, 'Not authorized'
+    flair_user = db.get_user(name=flair_user)
+    if not flair_user:
+        return 51, 'Not found'
+
+    page += f"# {flair_user.name}: Flairs\n"
+
+    # Action to add a new flair. This is a multi-step wizard.
+    if req.path.startswith(base_url + '/add/'):
+        found = re.search(r'/add/(\d+|-)?(/([\w-]+)(/(edit|[A-Za-z0-9]+))?)?$', req.path)
+        flair_type = found[1]
+        subspace_name = found[3]
+        subspace_urlpart = '/' + subspace_name if subspace_name else ''
+        action = found[5]
+
+        if not flair_type or flair_type == '-':
+            page += '\nSelect type of flair to add:\n\n'
+            index = 0
+            for index, (icon, desc) in enumerate(Flair.TYPES):
+                if index != Flair.MODERATOR_NOTE or (user.role == User.ADMIN or user_mod):
+                    page += f'=> {base_url}/add/{index}{subspace_urlpart} {space_separated(icon, desc)}\n'
+                index += 1
+            #page += f'=> {base_url}/add/note{subspace_urlpart} Custom note\n'
+            page += '\nCustom notes are only visible on post and user pages. The icons of other flairs are visible in feed entries, too.\n'
+            page += '\n=> /settings/flair Back to flairs\n'
+            return page
+
+        # Flair type is set but subspace may not be.
+        try:
+            subspace = None
+            is_global = False
+            sub_title = ''
+            if subspace_name:
+                if subspace_name == '-':
+                    is_global = True
+                    sub_title = ' in all subspaces'
+                    if user_mod:
+                        return 61, 'Not authorized'
+                else:
+                    subspace = db.get_subspace(name=subspace_name)
+                    sub_title = f' in {subspace.title()}'
+                    if user_mod and subspace.id not in moderated_subs:
+                        return 61, 'Not authorized'
+            icon, desc = Flair.TYPES[int(flair_type)]
+            page += f'\n## {space_separated(icon, desc)}{sub_title}\n\n'
+
+            base_url = base_url + f'/add/{flair_type}'
+
+            # Select the subspace.
+            if not subspace and not is_global:
+                page += 'Select subspace where the flair is displayed:\n\n'
+                if int(flair_type) != Flair.CUSTOM_NOTE or user.role == User.ADMIN:
+                    page += f'=> {base_url}/- All subspaces\n'
+                page += f'=> {base_url}/{user.name} {flair_user.avatar} u/{flair_user.name}\n\n'
+                for sub in db.get_subspaces(owner=0):
+                    page += f'=> {base_url}/{sub.name} {sub.title()}\n'
+                page += '\n=> /settings/flair Back to flairs\n'
+                return page
+
+            base_url += f'/{subspace_name}'
+
+            if action == 'edit':
+                if is_empty_query(req):
+                    return 10, TEXT_PROMPT
+                return 30, base_url + '?' + req.query
+            elif action and db.verify_token(user, action):
+                if int(flair_type) == Flair.MODERATOR_NOTE and user.role != User.ADMIN and not user_mod:
+                    return 61, 'Not authorized'
+                db.add_flair(flair_user,
+                             Flair(subspace.id if subspace else 0,
+                                   int(flair_type),
+                                   clean_query(req)[:TEXT_LIMIT],
+                                   is_admin_assigned=(user.role == User.ADMIN)),
+                             user)
+                return 30, session.path + f'settings/flair/' + flair_user.name
+
+            # Specify the flair text.
+            page += '### Flair text\n'
+            text = clean_query(req)
+            page += '(not set)' if not text else text
+            page += f'\n=> {base_url}/edit ✏️ Edit\n\n'
+
+            if text:
+                page += f'=> {base_url}/{session.get_token()}?{req.query} ✔️ Add the flair\n\n'
+            page += '=> /settings/flair Cancel\n'
+            return page
+
+        except Exception as x:
+            import traceback
+            traceback.print_tb(x.__traceback__)
+            print(x)
+            return 59, 'Bad request'
+
+    flairs = User.parse_flair(flair_user.flair)
+
+    # Edit and remove actions.
+    found = re.search(r'settings/flair/[\w-]+/(edit|remove)/(\d+)(/([A-Za-z0-9]+))?$', req.path)
+    if found:
+        action = found[1]
+        flair_index = int(found[2])
+        token = found[4]
+
+        if token and not db.verify_token(user, token):
+            return 61, 'Expired'
+        if flair_index < 0 or flair_index >= len(flairs):
+            return 51, 'Not found'
+
+        target = flairs[flair_index]
+
+        if target.is_admin_assigned and user.role != User.ADMIN:
+            return 61, 'Not authorized'
+        if target.type == Flair.MODERATOR_NOTE and (user.role != User.ADMIN and not user_mod):
+            return 61, 'Not authorized'
+
+        if action == 'remove':
+            db.remove_flair(flair_user, target, user)
+        elif action == 'edit':
+            if user_mod:
+                return 61, 'Not authorized'
+            if is_empty_query(req):
+                return 10, TEXT_PROMPT
+            target.label = clean_query(req)[:TEXT_LIMIT]
+            db.add_flair(flair_user, target, user)
+
+        return 30, base_url
+
+    # Compose the flair list page.
+    if user.id == flair_user.id:
+        page += f'=> /settings/profile ⚙️ Back to Profile\n'
+        page += f'=> /u/{flair_user.name} {flair_user.avatar} Back to u/{flair_user.name}\n\n'
+    elif user.role == User.ADMIN:
+        page += '=> /admin/ Back to Administration\n'
+        page += f'=> /u/{flair_user.name} {flair_user.avatar} u/{flair_user.name}\n\n'
+    elif user_mod:
+        page += '\nModerators can only edit flairs related to the moderated subspaces:\n'
+        for sid in moderated_subs:
+            msub = db.get_subspace(id=sid)
+            if msub:
+                page += f'=> {session.path}{msub.title()} {msub.title()}\n'
+
+    if not user_mod:
+        page += 'Flairs are labels that you can choose to show next to your name. They let you personalize your appearance in specific subspaces, acting as a sort of "body language" that is otherwise missing in a text-based medium. The administrator may also assign flairs if necessary.\n'
+        page += f'\n=> {base_url}/add/ Add a flair\n'
+
+    if len(flairs):
+        index = 0
+        for flair in flairs:
+            if flair.scope:
+                sub = db.get_subspace(id=flair.scope)
+                subspace = sub.title() if sub else 'a deleted subspace'
+                if user_mod and flair.scope not in moderated_subs:
+                    continue
+            else:
+                subspace = 'all subspaces'
+
+            page += '\n'
+            if flair.icon():
+                item = f'{flair.icon()} {flair.description()}: '
+            else:
+                item = '📛 '
+            item += f'{flair.label}'
+
+            if flair.is_admin_assigned and user.role != User.ADMIN:
+                page += f'{item}\n'
+                page += f'In: {subspace}\n'
+                page += '(assigned by administrator)\n'
+            else:
+                if not user_mod:
+                    page += f'=> {base_url}/edit/{index} {item}\n'
+                else:
+                    page += f'{item}\n'
+                page += f'In: {subspace}\n'
+                page += f'=> {base_url}/remove/{index}/{session.get_token()} ❌ Remove\n'
+            index += 1
+    else:
+        page += '\nYou have no flairs.\n'
+
+    return page
+
+
 def make_settings_page(session):
     req = session.req
     db = session.db
@@ -164,6 +374,9 @@ def make_settings_page(session):
             page += '\n' + note
         return page
 
+    elif req.path.startswith(session.path + 'settings/flair'):
+        return make_flair_composer_page(session)
+
     elif req.path == session.path + 'settings/feed-title':
         if req.query is None:
             return 10, f'Enter title for the u/{user.name} Gemini and Tinylog feeds:'
@@ -244,6 +457,7 @@ def make_settings_page(session):
         page += '\n## Profile\n\n'
         page += f'=> /settings/avatar/{token} Avatar: {session.user.avatar}\n'
         page += f'=> /settings/feed-title Feed title: {user_sub.info if user_sub.info else user.name}\n'
+        page += f'=> /settings/flair 📛 Flairs\n'
 
         page += '\n### Description\n'
         page += (session.user.info if session.user.info else '(no description)') + '\n'
@@ -305,6 +519,7 @@ def make_settings_page(session):
                 Notification.ADDED_AS_MODERATOR: 'Added as moderator',
                 Notification.REMOVED_AS_MODERATOR: 'Removed as moderator',
                 Notification.REPORT: 'Reported content (moderator)',
+                Notification.USER_FLAIR_CHANGED: 'User changed flairs (moderator)',
 
                 Notification.COMMENT_ON_COMMENTED: 'Reply in discussion',
                 Notification.COMMENT_ON_FOLLOWED_POST: 'Comment in followed post',
diff --git a/user.py b/user.py
index 2a40f5f..4aaf416 100644
--- a/user.py
+++ b/user.py
@@ -109,7 +109,7 @@ def user_actions(session):
         if not post:
             return 51, 'Not found'
         if token and db.verify_token(user, token):
-            db.notify_report(session.user, post)
+            db.notify_moderators(Notification.REPORT, session.user, post)
             return 30, post.page_url()
         subspace = db.get_subspace(id=post.subspace)
         kind = 'comment' if post.parent \
@@ -308,6 +308,8 @@ def user_actions(session):
             return 30, session.path + subs.title()
         notif_src = db.get_user(id=notif.src)
         if notif_src:
+            if notif.type == Notification.USER_FLAIR_CHANGED:
+                return 30, f'{session.path}settings/flair/{notif_src.name}'
             return 30, f'{session.path}u/{notif_src.name}'
         return 42, 'Invalid notification'
 
Proxy Information
Original URL
gemini://git.skyjake.fi/bubble/main/cdiff/ce031e623bc7c8cabad643d4d077aa79928d9877
Status Code
Success (20)
Meta
text/gemini; charset=utf-8
Capsule Response Time
31.431879 milliseconds
Gemini-to-HTML Time
1.856253 milliseconds

This content has been proxied by September (3851b).