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