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

Unified timeline combines posts and comments in a single feed.

The user is marked active when reacting to posts, thanking someone,
or interacting with notifications.
This commit is contained in:
Jaakko Keränen 2023-11-10 10:56:56 +02:00
parent 8cf782b697
commit bcdf1e7962
No known key found for this signature in database
GPG Key ID: BACCFCFB98DB2EDC
6 changed files with 122 additions and 57 deletions

View File

@ -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.

View File

@ -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:

View File

@ -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:

View File

@ -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)

View File

@ -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 ''

View File

@ -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: