Bubble [main]

Option for a unified timeline; mark user active on certain actions

=> bcdf1e79621ff22b5afba61ceab61aa3b658fac6

diff --git a/50_bubble.py b/50_bubble.py
index 960ced7..ff8bb37 100644
--- a/50_bubble.py
+++ b/50_bubble.py
@@ -232,6 +232,7 @@ Bubble is open source:
 
         def feed_entry(self, post, context=None, omit_rotate_info=False, is_activity_feed=False):
             is_issue_tracker = self.is_context_tracker
+            is_comment = post.parent != 0   # Flat feeds intermingle comments with posts.
 
             # Collect the metadata first.
             tag = ' ยท ' + post.tags if post.tags else ''
@@ -248,7 +249,7 @@ Bubble is open source:
                 if post.num_cmts > 0:
                     cmt += f'{post.num_cmts} comment{plural_s(post.num_cmts)}'
             else:
-                cmt = 'View post' if post.num_cmts == 0 and is_user_post else \
+                cmt = 'View post' if post.num_cmts == 0 and is_user_post and not is_comment else \
                       '' if post.num_cmts == 0 else \
                       f'{post.num_cmts} comment{plural_s(post.num_cmts)}'
             if is_activity_feed:
@@ -257,9 +258,6 @@ Bubble is open source:
                 age = post.age(tz=self.tz)
             bell = ' ๐Ÿ””' if post.num_notifs else ''
 
-            author = '๐ŸŒ’ ' + sub if sub else (post.poster_avatar + ' ' + post.poster_name)
-            author_link = f'/{sub}' if sub else f'/u/{post.poster_name}'
-
             SHORT_PREVIEW_LEN = 160
 
             if is_issue_tracker:
@@ -268,41 +266,79 @@ Bubble is open source:
                 src = f'=> {post.page_url()} ๐Ÿž [#{post.issueid if post.issueid else 0}] {post.title}{tag}\n'
                 src += shorten_text(post.summary,
                                     200 if not self.is_short_preview else SHORT_PREVIEW_LEN) + '\n'
-                parts = []
-                parts.append(post.poster_name)
+                meta = []
+                meta.append(post.poster_name)
                 if cmt:
-                    parts.append(cmt)
+                    meta.append(cmt)
                 if likes:
-                    parts.append(likes)
-                parts.append(age)
-                src += f'{post.poster_avatar} {" ยท ".join(parts)}{bell}\n'
+                    meta.append(likes)
+                meta.append(age)
+                src += f'{post.poster_avatar} {" ยท ".join(meta)}{bell}\n'
             else:
+                # Regular feeds may have subspace posts, user posts, and comments.
+                is_deleted = False
+                reply_label, reply_path = None, None
+                if is_comment:
+                    post_icon = '๐ŸŒ’' if not post.sub_owner else ''
+                    post_label = ("s/" if not post.sub_owner else "u/") + post.sub_name
+                    post_path = '/' + post_label
+                    meta_icon = post.poster_avatar
+                    parent_post = self.db.get_post(id=post.parent)
+                    if parent_post:
+                        reply_label = f"Re: {parent_post.quoted_title(max_len=60)}"
+                        reply_path = parent_post.page_url()
+                    else:
+                        reply_label = "Re: (deleted post)"
+                        reply_path = post.page_url()
+                        is_deleted = True
+                elif sub:
+                    post_icon = '๐ŸŒ’'
+                    post_label = sub
+                    post_path = f'/{sub}'
+                    meta_icon = '๐Ÿ’ฌ'
+                else:
+                    post_icon = post.poster_avatar
+                    post_label = post.poster_name
+                    post_path = f'/u/{post.poster_name}'
+                    meta_icon = '๐Ÿ’ฌ'
+
+                # Grouped posts are shown in a rotating group.
                 rotation = ''
-                if not omit_rotate_info:
-                    # Posts may be rotating per day.
+                if not is_comment and not omit_rotate_info:
                     per_day = post.num_per_day
                     if per_day and per_day > 1:
                         n = per_day - 1
                         rotation = f" (+{n} other post{plural_s(n)})"
-                        author_link = post.page_url() + "/group"
-                if is_activity_feed:
-                    if sub:
-                        author += f' ยท {post.poster_avatar} {post.poster_name}'
-                # First line is the author.
-                src = f'=> {author_link} {author}{rotation}\n'
-                src += post.summary if not self.is_short_preview \
-                    else shorten_text(post.summary, SHORT_PREVIEW_LEN) + '\n'
-                parts = []
-                if sub and not is_activity_feed:
-                    parts.append(post.poster_name)
+                        post_path = post.page_url() + "/group"
+
+                # 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}'
+
+                src = f'=> {post_path} {post_icon} {post_label}{rotation}\n'
+
+                if reply_label:
+                    src += f'=> {reply_path} {INNER_LINK_PREFIX} {reply_label}\n'
+
+                if not is_deleted:
+                    src += post.summary if not self.is_short_preview \
+                        else shorten_text(post.summary, SHORT_PREVIEW_LEN) + '\n'
+                else:
+                    src += "(only visible to author)\n"
+
+                # Last line in the metadata.
+                meta = []
+                if is_comment or (sub and not is_activity_feed):
+                    meta.append(post.poster_name)
                 if cmt:
-                    parts.append(cmt)
+                    meta.append(cmt)
                 if likes:
-                    parts.append(likes)
-                if len(parts) == 0:
-                    parts.append('View post')
-                parts.append(age)
-                src += f'=> {post.page_url()} ๐Ÿ’ฌ {" ยท ".join(parts)}{bell}{tag}\n'
+                    meta.append(likes)
+                if len(meta) == 0:
+                    meta.append('View post')
+                meta.append(age)
+                src += f'=> {post.page_url()} {meta_icon} {" ยท ".join(meta)}{bell}{tag}\n'
 
             return src
 
@@ -801,7 +837,7 @@ when the administrator assigns at least one moderator to it.
 
 ## Deleted Posts
 
-Deleting a post does not delete its discussion thread, too, because the post author does not have the authority to delete other users' content. After a post has been deleted, comments about it are still accessible through the Dashboard comment index. You can find your orphaned comments in the index by searching for comments about a "Deleted post". Comments about deleted posts are not included in any feed.
+Deleting a post does not delete its discussion thread because users cannot delete other users' content. After a post has been deleted, comments you have made about it are still visible to you through the Dashboard comment index. You can find your orphaned comments in the index by searching for comments about a "Deleted post". Comments about deleted posts are not included in any feed.
 
 Deleting a subspace will delete all posts and comments in the subspace, i.e., the full discussion threads will be deleted.
 
diff --git a/feeds.py b/feeds.py
index 01e7275..d1fb1fa 100644
--- a/feeds.py
+++ b/feeds.py
@@ -331,7 +331,7 @@ def make_post_page(session, post):
         post = db.get_post(id=post_id)
         page += f'# Comment by {focused_cmt.poster_avatar} {focused_cmt.poster_name}\n\n'
         if post:
-            page += f'=> {post.page_url()} Re: "{post.title if post.title else shorten_text(strip_links(clean_title(post.summary)), 60)}"\n'
+            page += f'=> {post.page_url()} Re: {post.quoted_title()}\n'
             sub_name = ("u/" if post.sub_owner else "s/") + post.sub_name
             page += f'=> /{sub_name} In: {sub_name}\n\n'
         else:
@@ -638,7 +638,13 @@ def make_feed_page(session):
     else:
         is_bubble_feed = True
 
+    is_flat_feed = (is_bubble_feed
+                    and not is_issue_tracker
+                    and user
+                    and user.sort_post == User.SORT_POST_FLAT)
     feed_sort_mode = Post.SORT_CREATED
+    omit_user_subspaces = False
+    omit_nonuser_subspaces = False
     rotate_per_day = False
     page_size = 50 if is_gemini_feed else 100 if is_tinylog else 25
     page_index = 0
@@ -710,16 +716,22 @@ def make_feed_page(session):
         sort_mode = ' ๐Ÿ”ฅ' if feed_sort_mode == Post.SORT_HOTNESS \
             else ' ๐Ÿ—ฃ๏ธ' if feed_sort_mode == Post.SORT_ACTIVE else ''
 
+        if is_flat_feed:
+            sort_mode = ' ๐Ÿ’ฌ'
+            feed_sort_mode = Post.SORT_CREATED
+
         omit_user_subspaces = (user.flags & User.HOME_NO_USERS_FEED_FLAG) != 0
         omit_nonuser_subspaces = (user.flags & User.HOME_USERS_FEED_FLAG) != 0
         rotate_per_day = (session.is_rotation_enabled()
                           and not context
+                          and not is_flat_feed
                           and feed_sort_mode == Post.SORT_CREATED
                           and not filter_by_followed)
 
         # Pagination.
         num_total = db.count_posts(subspace=context,
                                    draft=False,
+                                   is_comment=None if is_flat_feed else False,
                                    filter_by_followed=filter_by_followed,
                                    filter_issue_status=filter_issue_status,
                                    filter_tag=session.feed_tag_filter,
@@ -794,7 +806,7 @@ def make_feed_page(session):
         page += f'avatar: {c_user.avatar}\n\n'
 
     posts = db.get_posts(subspace=context,
-                         comment=False,
+                         comment=None if is_flat_feed else False,
                          draft=False,
                          sort=feed_sort_mode,
                          notifs_for_user_id=(user.id if user else 0),
@@ -865,12 +877,13 @@ def make_feed_page(session):
     elif not is_tinylog:
         if not is_gemini_feed:
             page += "## Options\n"
-            if feed_sort_mode != Post.SORT_CREATED:
-                page += "=> ?sort=new ๐Ÿ•‘ Sort by most recent\n"
-            if feed_sort_mode != Post.SORT_ACTIVE:
-                page += "=> ?sort=active ๐Ÿ—ฃ๏ธ Sort by activity\n"
-            if feed_sort_mode != Post.SORT_HOTNESS:
-                page += "=> ?sort=hot ๐Ÿ”ฅ Sort by hotness\n"
+            if not is_flat_feed:
+                if feed_sort_mode != Post.SORT_CREATED:
+                    page += "=> ?sort=new ๐Ÿ•‘ Sort by most recent\n"
+                if feed_sort_mode != Post.SORT_ACTIVE:
+                    page += "=> ?sort=active ๐Ÿ—ฃ๏ธ Sort by activity\n"
+                if feed_sort_mode != Post.SORT_HOTNESS:
+                    page += "=> ?sort=hot ๐Ÿ”ฅ Sort by hotness\n"
             if not context:
                 if session.feed_mode == 'followed':
                     page += '=> /all All Posts\n'
@@ -887,7 +900,7 @@ def make_feed_page(session):
                         page += f'=> /{context.title()}/search ๐Ÿ” Search in {context.title()}\n'
                         page += f'=> /{context.title()}/tag ๐Ÿท๏ธ Tags\n'
                     else:
-                        page += '=> /search ๐Ÿ” Search\n'
+                        page += '\n=> /search ๐Ÿ” Search\n'
                         page += '=> /tag ๐Ÿท๏ธ Tags\n'
 
                 # Settings.
@@ -897,7 +910,6 @@ def make_feed_page(session):
 
                 if session.is_antenna_enabled() and c_user and user.id == c_user.id:
                     antenna_feed = f"{session.server_root()}{session.path}u/{user.name}/antenna"
-                    #page += f'=> {session.bubble.antenna_url}?{urlparse.quote(antenna_feed)} Submit feed to ๐Ÿ“ก Antenna\n'
                     for link in session.bubble.antenna_links('feed', antenna_feed):
                         page += link
 
@@ -915,7 +927,8 @@ def make_feed_page(session):
                         page += f'You will not see posts or comments by {c_user.name} anywhere on {session.bubble.site_name}.\n'
                 if context and context.owner != user.id and not session.is_context_locked:
                     if not c_user or not (MUTE_USER, c_user.id) in user_mutes:
-                        page += '\n'
+                        if not page.endswith('\n\n'):
+                            page += '\n'
                         if (MUTE_SUBSPACE, context.id) in user_mutes:
                             page += f'=> /unmute/{context.title()} ๐Ÿ”ˆ Unmute subspace {context.title()}\n'
                         elif (FOLLOW_SUBSPACE, context.id) in user_follows:
diff --git a/model.py b/model.py
index 6c613f2..9c0a80a 100644
--- a/model.py
+++ b/model.py
@@ -227,6 +227,7 @@ class User:
     SORT_POST_RECENT    = 'r'
     SORT_POST_HOTNESS   = 'h'
     SORT_POST_ACTIVITY  = 'a'
+    SORT_POST_FLAT      = 'f'
     SORT_COMMENT_OLDEST = 'o'
     SORT_COMMENT_NEWEST = 'n'
 
@@ -376,6 +377,8 @@ class Post:
         self.num_per_day = num_per_day
 
     def title_text(self):
+        """Title shown in the composer."""
+
         if len(self.title):
             return self.title
         elif self.parent:
@@ -386,6 +389,9 @@ class Post:
         else:
             return '(untitled issue)' if self.issueid else '(untitled post)'
 
+    def quoted_title(self, max_len=60):
+        return f'"{self.title if self.title else shorten_text(strip_links(clean_title(self.summary)), max_len)}"'
+
     def ymd_date(self, tz=None):
         dt = datetime.datetime.fromtimestamp(self.ts_created, UTC)
         if tz:
@@ -1839,7 +1845,7 @@ class Database:
                 UNIX_TIMESTAMP(p.ts_comment) AS ts_comment,
                 p.summary,
                 sub1.name AS sub_name,
-                sub2.owner,
+                sub1.owner,   -- sub2.owner,
                 u.avatar,
                 u.name AS u_name,
                 (SELECT COUNT(notifs.id)
@@ -1851,7 +1857,7 @@ class Database:
             FROM posts p
                 JOIN users u ON p.user=u.id
                 JOIN subspaces sub1 ON p.subspace=sub1.id
-                LEFT JOIN subspaces sub2 ON p.subspace=sub2.id AND p.user=sub2.owner
+                -- LEFT JOIN subspaces sub2 ON p.subspace=sub2.id AND p.user=sub2.owner
                 {filter}
             WHERE {' AND '.join(where_stm)}
         """
@@ -1945,7 +1951,7 @@ class Database:
                     subspace=None,
                     parent_id=None,
                     draft=False,
-                    is_comment=None,
+                    is_comment=False,
                     ignore_omit_flags=False,
                     omit_user_subspaces=False,
                     omit_nonuser_subspaces=False,
@@ -1959,10 +1965,10 @@ class Database:
         grouping = ''
         filter = ''
 
-        if is_comment:
+        if is_comment != None:
             cond.append('p.parent!=0' if is_comment else 'p.parent=0')
-        elif not parent_id and not draft:
-            cond.append('p.parent=0') # no comments
+        # elif not parent_id and not draft:
+        #     cond.append('p.parent=0') # no comments
         if filter_by_followed:
             filter = Database.FOLLOW_FILTER_JOIN
             values.append(filter_by_followed.id)
@@ -2170,6 +2176,7 @@ class Database:
             END IF
         """, (user.id, post.user, post.id,
               user.id, post.user, post.id))
+        self.update_user(user, active=True)
         self.commit()
 
     def notify_new_poll(self, post: Post):
@@ -2236,6 +2243,7 @@ class Database:
                         (post.id, user.id, reaction))
             cur.execute("INSERT IGNORE INTO notifs (type, src, dst, post) VALUES (?, ?, ?, ?)",
                         (Notification.REACTION, user.id, post.user, post.id))
+            self.update_user(user, active=True)
         self.commit()
 
     def get_reactions(self, post, user_mutes=set()) -> dict:
diff --git a/settings.py b/settings.py
index ddf0975..24a9c17 100644
--- a/settings.py
+++ b/settings.py
@@ -28,7 +28,8 @@ def make_settings_page(session):
     SORT_POST = {
         'r': '๐Ÿ•‘ Most recent',
         'a': '๐Ÿ—ฃ๏ธ Activity',
-        'h': '๐Ÿ”ฅ Hotness'
+        'h': '๐Ÿ”ฅ Hotness',
+        'f': '๐Ÿ—ช Unified timeline'
     }
 
     if req.path == session.path + 'settings/avatar/' + token:
@@ -253,7 +254,7 @@ def make_settings_page(session):
             page = 'Sort posts by:\n\n'
             for key, label in SORT_POST.items():
                 page += f"=> ?{key} {label}\n"
-            page += '\n"Most recent" sorts posts by their original creation time. "Activity" is affected by the time of the latest comment: posts will be bumped to the top of a feed whenever a new comment is added. "Hotness" sorts posts by a score calculated based on time of latest comment, number of people in the discussion thread, number of likes, and the age of the post.'
+            page += '\n"Most recent" sorts posts by their original creation time. "Activity" is affected by the time of the latest comment: posts will be bumped to the top of a feed whenever a new comment is added. "Hotness" sorts posts by a score calculated based on time of latest comment, number of people in the discussion thread, number of likes, and the age of the post. "Unified timeline" combines posts and comments in one feed in reverse chronological order.'
             return page
         if not arg in SORT_POST:
             return 50, 'Invalid sort order'
@@ -268,11 +269,15 @@ def make_settings_page(session):
         return 30, '/settings'
 
     elif req.path == session.path + 'settings/ascii':
-        db.update_user(session.user, flags=(session.user.flags ^ User.ASCII_ICONS_FLAG))
+        db.update_user(user, flags=(user.flags ^ User.ASCII_ICONS_FLAG))
         return 30, './display'
 
     elif req.path == session.path + 'settings/short-preview':
-        db.update_user(session.user, flags=(session.user.flags ^ User.SHORT_PREVIEW_FLAG))
+        db.update_user(user, flags=(user.flags ^ User.SHORT_PREVIEW_FLAG))
+        return 30, './display'
+
+    elif req.path == session.path + 'settings/flat':
+        db.update_user(user, flags=(user.flags ^ User.HOME_FLAT_FEED_FLAG))
         return 30, './display'
 
     elif req.path == session.path + 'settings/all-rotation':
@@ -609,14 +614,14 @@ def make_settings_page(session):
         ICON_MODE = [ 'Unicode/Emoji' , 'ASCII' ]
 
         page += '## Display\n\n'
-        page += f'=> /settings/short-preview {CHECKS[nonzero(session.user.flags & User.SHORT_PREVIEW_FLAG)]} Short post previews\n'
-        page += f"=> /settings/all-rotation {CHECKS[is_zero(session.user.flags & User.DISABLE_ROTATION_FLAG)]} Rotate posts by subspace in All Posts\n"
-        if session.user.flags & User.DISABLE_ROTATION_FLAG:
+        page += f'=> /settings/short-preview {CHECKS[nonzero(user.flags & User.SHORT_PREVIEW_FLAG)]} Short post previews\n'
+        page += f"\n=> /settings/all-rotation {CHECKS[is_zero(user.flags & User.DISABLE_ROTATION_FLAG)]} Rotate posts by subspace in All Posts\n"
+        if user.flags & User.DISABLE_ROTATION_FLAG:
             page += 'The All Posts feed shows every post individually, even when one subspace has several posts per day.\n'
         else:
             page += 'The All Posts feed groups posts by subspace and rotates them throughout the day.\n'
         page += f'\n=> /settings/timezone Time zone: {user.timezone}\n'
-        page += f'=> /settings/ascii Display icons as: {ICON_MODE[nonzero(session.user.flags & User.ASCII_ICONS_FLAG)]}\n'
+        page += f'=> /settings/ascii Display icons as: {ICON_MODE[nonzero(user.flags & User.ASCII_ICONS_FLAG)]}\n'
         return page
 
     elif req.path == session.path + 'settings' or \
@@ -633,7 +638,7 @@ def make_settings_page(session):
             0: 'All Posts',
             User.HOME_NO_USERS_FEED_FLAG: 'All Posts (excluding userspaces)',
             User.HOME_USERS_FEED_FLAG: 'Userspaces only',
-            User.HOME_FOLLOWED_FEED_FLAG: 'Followed'
+            User.HOME_FOLLOWED_FEED_FLAG: 'Followed',
         }
         user_space = db.get_subspace(owner=user.id)
 
diff --git a/subspace.py b/subspace.py
index f2caf5a..85c2e17 100644
--- a/subspace.py
+++ b/subspace.py
@@ -92,7 +92,7 @@ def make_subspaces_page(session):
     def sub_latest_post(sub):
         latest = db.get_post(id=sub.latest_post_id) if sub.latest_post_id else None
         if latest:
-            title = f'"{latest.title}"' if latest.title else f'"{shorten_text(latest.summary, 60)}"'
+            title = latest.quoted_title()
             age = latest.age(tz=session.tz)
             return f"{title} by {latest.poster_avatar} {latest.poster_name} ยท {age}\n"
         return ''
diff --git a/user.py b/user.py
index d512f3f..3213ba5 100644
--- a/user.py
+++ b/user.py
@@ -190,6 +190,7 @@ def user_actions(session):
 
         if action == 'clear':
             db.get_notifications(session.user, clear=True)
+            db.update_user(session.user, active=True)
             return 30, '/dashboard'
 
         if action == 'history':
@@ -218,6 +219,8 @@ def user_actions(session):
             return page
 
         notif = db.get_notification(session.user, notif_id, clear=True)
+        db.update_user(session.user, active=True)
+
         if not notif:
             return 30, '/dashboard'
         if notif.comment:
Proxy Information
Original URL
gemini://git.skyjake.fi/bubble/main/cdiff/bcdf1e79621ff22b5afba61ceab61aa3b658fac6
Status Code
Success (20)
Meta
text/gemini; charset=utf-8
Capsule Response Time
32.651953 milliseconds
Gemini-to-HTML Time
0.71362 milliseconds

This content has been proxied by September (ba2dc).